diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 45439ae92c..7955082fc7 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -11,6 +11,8 @@ assignees: '' Thanks for filing an issue! Please use the prompts below to provide as much context as you can about your use case and motivations. --> +Gong snippet: TODO + ## Problem -- [ ] UI changes: TODO -- [ ] CLI usage changes: TODO -- [ ] REST API changes: TODO -- [ ] Fleet's agent (fleetd) changes: TODO -- [ ] Permissions changes: TODO -- [ ] Changes to paid features or tiers: TODO +- [ ] UI changes: TODO +- [ ] CLI (fleetctl) usage changes: TODO +- [ ] YAML changes: TODO +- [ ] REST API changes: TODO +- [ ] Fleet's agent (fleetd) changes: TODO +- [ ] Activity changes: TODO +- [ ] Permissions changes: TODO +- [ ] Changes to paid features or tiers: TODO +- [ ] Other reference documentation changes: TODO +- [ ] Once shipped, requester has been notified ### Engineering - [ ] Feature guide changes: TODO diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index af1f4818cf..cf6e9c46e2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,6 +9,7 @@ If some of the following don't apply, delete the relevant line. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [ ] Added support on fleet's osquery simulator `cmd/osquery-perf` for new osquery data ingestion features. - [ ] Added/updated tests +- [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes - [ ] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. diff --git a/.github/workflows/build-and-push-fleetctl-docker.yml b/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml similarity index 51% rename from .github/workflows/build-and-push-fleetctl-docker.yml rename to .github/workflows/build-and-check-fleetctl-docker-and-deps.yml index 8ae3c7069e..ff20260409 100644 --- a/.github/workflows/build-and-push-fleetctl-docker.yml +++ b/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml @@ -1,13 +1,14 @@ -name: Build and push fleetdm/fleetctl Docker image +name: Build fleetctl docker dependencies and check vulnerabilities -# Manually trigger this workflow for now on: workflow_dispatch: inputs: image_tag: - description: 'Docker image tag' + description: "Docker image tag" required: true type: string + schedule: + - cron: "0 6 * * *" # This allows a subsequently queued workflow run to interrupt previous runs concurrency: @@ -23,7 +24,7 @@ permissions: contents: read jobs: - docker-push: + build-and-check: runs-on: ubuntu-latest environment: Docker Hub permissions: @@ -46,25 +47,46 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Install Go Dependencies run: make deps-go + - name: Build fleetdm/wix + run: make wix-docker + + - name: Build fleetdm/bomutils + run: make bomutils-docker + - name: Build fleetdm/fleetctl run: make fleetctl-docker - - name: Push to Docker - run: | - docker tag fleetdm/fleetctl fleetdm/fleetctl:${{ inputs.image_tag }} - docker push fleetdm/fleetctl:${{ inputs.image_tag }} - - - name: Push To quay.io - id: push-to-quay - uses: redhat-actions/push-to-registry@9986a6552bc4571882a4a67e016b17361412b4df # v2.7.1 + - name: Run Trivy vulnerability scanner on fleetdm/wix + uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 with: - image: fleetdm/fleetctl - tags: ${{ inputs.image_tag }} - registry: quay.io/ - username: fleetdm+fleetreleaser - password: ${{ secrets.QUAY_REGISTRY_PASSWORD }} + image-ref: "fleetdm/wix" + format: "table" + exit-code: "1" + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL" + + - name: Run Trivy vulnerability scanner on fleetdm/bomutils + uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 + with: + image-ref: "fleetdm/bomutils" + format: "table" + exit-code: "1" + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL" + + - name: Run Trivy vulnerability scanner on fleetdm/fleetctl + uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 + with: + image-ref: "fleetdm/fleetctl" + format: "table" + exit-code: "1" + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL" diff --git a/.github/workflows/build-binaries.yaml b/.github/workflows/build-binaries.yaml index ed18437c74..278f958b28 100644 --- a/.github/workflows/build-binaries.yaml +++ b/.github/workflows/build-binaries.yaml @@ -29,10 +29,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} @@ -40,9 +43,6 @@ jobs: with: node-version: ${{ vars.NODE_VERSION }} - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - name: JS Dependency Cache id: js-cache uses: actions/cache@69d9d449aced6a2ede0bc19182fadc3a0a42d2b0 # v2 diff --git a/.github/workflows/build-orbit.yaml b/.github/workflows/build-orbit.yaml index 09f296aece..002d2657f6 100644 --- a/.github/workflows/build-orbit.yaml +++ b/.github/workflows/build-orbit.yaml @@ -59,7 +59,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Build, codesign and notarize orbit run: go run ./orbit/tools/build/build.go diff --git a/.github/workflows/check-automated-doc.yml b/.github/workflows/check-automated-doc.yml index c654e7ae4f..d289c55318 100644 --- a/.github/workflows/check-automated-doc.yml +++ b/.github/workflows/check-automated-doc.yml @@ -36,15 +36,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Verify golang generated documentation is up-to-date run: | make generate-doc diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 246c6418a1..c69888f874 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -56,7 +56,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/deploy-bulk-operations-dashboard.yml b/.github/workflows/deploy-bulk-operations-dashboard.yml new file mode 100644 index 0000000000..090e409fac --- /dev/null +++ b/.github/workflows/deploy-bulk-operations-dashboard.yml @@ -0,0 +1,89 @@ +name: Deploy app to bulk operations dashboard pipeline on Heroku. + +on: + push: + branches: [ main ] + paths: + - 'ee/bulk-operations-dashboard/**' + +permissions: + contents: read + +jobs: + build: + permissions: + contents: write # for Git to git push + if: ${{ github.repository == 'fleetdm/fleet' }} + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x] + + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + # Configure our access credentials for the Heroku CLI + - uses: akhileshns/heroku-deploy@79ef2ae4ff9b897010907016b268fd0f88561820 # v3.6.8 + with: + heroku_api_key: ${{secrets.HEROKU_API_TOKEN_FOR_BOT_USER}} + heroku_app_name: "" # this has to be blank or it doesn't work + heroku_email: ${{secrets.HEROKU_EMAIL_FOR_BOT_USER}} + justlogin: true + - run: heroku auth:whoami + + # Set the Node.js version + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1 + with: + node-version: ${{ matrix.node-version }} + + # Now start building! + # > …but first, get a little crazy for a sec and delete the top-level package.json file + # > i.e. the one used by the Fleet server. This is because require() in node will go + # > hunting in ancestral directories for missing dependencies, and since some of the + # > bundled transpiler tasks sniff for package availability using require(), this trips + # > up when it encounters another Node universe in the parent directory. + - run: rm -rf package.json package-lock.json node_modules/ + # > Turns out there's a similar issue with how eslint plugins are looked up, so we + # > delete the top level .eslintrc file too. + - run: rm -f .eslintrc.js + # > And, as a change to the top-level fleetdm/fleet .gitignore on May 2, 2022 revealed, + # > we also need to delete the top level .gitignore file too, so that its rules don't + # > interfere with the committing and force-pushing we're doing as part of our deploy + # > script here. For more info, see: https://github.com/fleetdm/fleet/pull/5549 + - run: rm -f .gitignore + + # Get dependencies (including dev deps) + - run: cd ee/bulk-operations-dashboard/ && npm install + + # Run sanity checks + - run: cd ee/bulk-operations-dashboard/ && npm test + + # Compile assets + - run: cd ee/bulk-operations-dashboard/ && npm run build-for-prod + + # Commit newly-built assets locally so we can push them to Heroku below. + # (This commit will never be pushed to GitHub- only to Heroku.) + # > The local config flags make this work in GitHub's environment. + - run: git add ee/bulk-operations-dashboard/.www + - run: git -c "user.name=GitHub" -c "user.email=github@example.com" commit -am 'AUTOMATED COMMIT - Deployed the latest, including modified HTML layouts and .sailsrc file that reference minified assets.' + + # Configure the Heroku app we'll be deploying to + - run: heroku git:remote -a bulk-operations-dashboard + - run: git remote -v + + # Deploy to Heroku (by pushing) + # > Since a shallow clone was grabbed, we have to "unshallow" it before forcepushing. + - run: echo "Unshallowing local repository…" + - run: git fetch --prune --unshallow + - run: echo "Deploying branch '${GITHUB_REF##*/}' to Heroku…" + - run: git push heroku +${GITHUB_REF##*/}:master + - name: 🌐 The dashboard has been deployed + run: echo '' && echo '--' && echo 'OK, done. It should be live momentarily.' && echo '(if you get impatient, check the Heroku dashboard for status)' diff --git a/.github/workflows/deploy-fleet-website.yml b/.github/workflows/deploy-fleet-website.yml index 9fc044e13b..371a0014f0 100644 --- a/.github/workflows/deploy-fleet-website.yml +++ b/.github/workflows/deploy-fleet-website.yml @@ -64,7 +64,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Download top-level dependencies and build Storybook in the website's assets/ folder - run: npm install --legacy-peer-deps && npm run build-storybook -- -o ./website/assets/storybook --loglevel verbose diff --git a/.github/workflows/dogfood-deploy.yml b/.github/workflows/dogfood-deploy.yml index 39f6983824..f17768eec7 100644 --- a/.github/workflows/dogfood-deploy.yml +++ b/.github/workflows/dogfood-deploy.yml @@ -51,14 +51,17 @@ jobs: - id: fail-on-main run: "false" if: ${{ github.ref == 'main' }} + - uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # v1.7.0 with: role-to-assume: ${{env.AWS_IAM_ROLE}} aws-region: ${{ env.AWS_REGION }} + - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' + - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 with: terraform_version: 1.6.3 @@ -77,6 +80,26 @@ jobs: id: plan run: terraform plan -no-color continue-on-error: true + - name: Slack Notification + if: success() + uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0 + with: + payload: | + { + "text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "🚀 🛠️ Dogfood deploy in progress\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK # first we'll scale everything down and create the new task definitions - name: Terraform Apply id: apply diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index 8b2e3217c0..487cdac1ad 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -69,6 +69,7 @@ jobs: DOGFOOD_GLOBAL_ENROLL_SECRET: ${{ secrets.DOGFOOD_GLOBAL_ENROLL_SECRET }} DOGFOOD_SSO_ISSUER_URI: ${{ secrets.DOGFOOD_SSO_ISSUER_URI }} DOGFOOD_SSO_METADATA: ${{ secrets.DOGFOOD_SSO_METADATA }} + DOGFOOD_MDM_SSO_METADATA_URL: ${{ secrets.DOGFOOD_MDM_SSO_METADATA_URL }} DOGFOOD_FAILING_POLICIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_FAILING_POLICIES_WEBHOOK_URL }} DOGFOOD_VULNERABILITIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_VULNERABILITIES_WEBHOOK_URL }} DOGFOOD_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_ENROLL_SECRET }} diff --git a/.github/workflows/fleet-and-orbit.yml b/.github/workflows/fleet-and-orbit.yml index 4cab7da482..f4dfb2780e 100644 --- a/.github/workflows/fleet-and-orbit.yml +++ b/.github/workflows/fleet-and-orbit.yml @@ -62,7 +62,6 @@ jobs: timeout-minutes: 60 strategy: matrix: - go-version: ["${{ vars.GO_VERSION }}"] mysql: ["mysql:8.0.36"] runs-on: ubuntu-latest needs: gen @@ -72,10 +71,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} @@ -83,9 +85,6 @@ jobs: with: node-version: ${{ vars.NODE_VERSION }} - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - name: Start tunnel env: CERT_PEM: ${{ secrets.CLOUDFLARE_TUNNEL_FLEETUEM_CERT_B64 }} @@ -111,7 +110,7 @@ jobs: done - name: Start Infra Dependencies - run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose up -d mysql redis & + run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker compose up -d mysql redis & - name: Install JS Dependencies run: make deps-js @@ -175,9 +174,6 @@ jobs: # This job also makes sure the Fleet server is up and running. set-enroll-secret: timeout-minutes: 60 - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] runs-on: ubuntu-latest needs: gen steps: @@ -186,13 +182,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl @@ -218,9 +214,6 @@ jobs: # Here we generate the Fleet Desktop and osqueryd targets for # macOS which can only be generated from a macOS host. build-macos-targets: - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] # Set macOS version to '12' (current equivalent to macos-latest) for # building the binary. This ensures compatibility with macOS version 13 and # later, avoiding runtime errors on systems using macOS 13 or newer. @@ -234,13 +227,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build desktop.app.tar.gz and osqueryd.app.tar.gz run: | @@ -269,9 +262,6 @@ jobs: # installed, and installing it is time consuming and unreliable. run-tuf-and-gen-pkgs: timeout-minutes: 60 - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] runs-on: ubuntu-latest needs: [gen, build-macos-targets] steps: @@ -280,13 +270,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Download macos pre-built apps id: download diff --git a/.github/workflows/fleetctl-preview-latest.yml b/.github/workflows/fleetctl-preview-latest.yml index dda4e0f73c..630cfd1dc3 100644 --- a/.github/workflows/fleetctl-preview-latest.yml +++ b/.github/workflows/fleetctl-preview-latest.yml @@ -53,7 +53,6 @@ jobs: # - Unattended installation of Docker on macOS fails. (see # https://github.com/docker/for-mac/issues/6450) os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -62,13 +61,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl diff --git a/.github/workflows/fleetd-tuf.yml b/.github/workflows/fleetd-tuf.yml index ebeca889da..7641589f10 100644 --- a/.github/workflows/fleetd-tuf.yml +++ b/.github/workflows/fleetd-tuf.yml @@ -30,16 +30,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Update orbit/TUF.md run: | make fleetd-tuf diff --git a/.github/workflows/generate-desktop-targets.yml b/.github/workflows/generate-desktop-targets.yml index d8269e9948..93e5a30fce 100644 --- a/.github/workflows/generate-desktop-targets.yml +++ b/.github/workflows/generate-desktop-targets.yml @@ -13,18 +13,13 @@ on: - '.github/workflows/generate-desktop-targets.yml' workflow_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 env: - FLEET_DESKTOP_VERSION: 1.29.0 + FLEET_DESKTOP_VERSION: 1.33.0 permissions: contents: read @@ -45,13 +40,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Import signing keys env: @@ -98,13 +93,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate fleet-desktop.exe run: | @@ -139,13 +134,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate desktop.tar.gz run: | @@ -167,13 +162,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate desktop.tar.gz run: | diff --git a/.github/workflows/generate-osqueryd-targets.yml b/.github/workflows/generate-osqueryd-targets.yml index 12e08bddac..b27e074b6a 100644 --- a/.github/workflows/generate-osqueryd-targets.yml +++ b/.github/workflows/generate-osqueryd-targets.yml @@ -24,7 +24,7 @@ defaults: shell: bash env: - OSQUERY_VERSION: 5.12.2 + OSQUERY_VERSION: 5.13.1 permissions: contents: read diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index df6b9792b7..3d3e95ed2c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -38,7 +38,6 @@ jobs: matrix: # See #9943, we just need to add windows-latest here once all issues are fixed. os: [ubuntu-latest, macos-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: - name: Harden Runner @@ -52,7 +51,7 @@ jobs: - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} + go-version-file: 'go.mod' - name: Install dependencies (Linux) if: matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/goreleaser-fleet.yaml b/.github/workflows/goreleaser-fleet.yaml index 8de0089b62..6ba9aff8f0 100644 --- a/.github/workflows/goreleaser-fleet.yaml +++ b/.github/workflows/goreleaser-fleet.yaml @@ -20,7 +20,7 @@ permissions: jobs: goreleaser: - runs-on: ubuntu-20.04 + runs-on: ubuntu-20.04-4-cores environment: Docker Hub permissions: contents: write @@ -44,7 +44,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} diff --git a/.github/workflows/goreleaser-orbit.yaml b/.github/workflows/goreleaser-orbit.yaml index 666f281120..e196901ead 100644 --- a/.github/workflows/goreleaser-orbit.yaml +++ b/.github/workflows/goreleaser-orbit.yaml @@ -5,11 +5,6 @@ on: tags: - "orbit-*" # For testing, use a pre-release tag like 'orbit-1.24.0-1' -# 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 @@ -56,7 +51,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-macos.yml # v1.20.0 @@ -95,7 +90,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-linux.yml # v1.20.0 @@ -128,7 +123,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-linux-arm64.yml # v1.20.0 @@ -161,7 +156,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-windows.yml # v1.20.0 diff --git a/.github/workflows/goreleaser-snapshot-fleet.yaml b/.github/workflows/goreleaser-snapshot-fleet.yaml index 46c1da4193..927cf31be1 100644 --- a/.github/workflows/goreleaser-snapshot-fleet.yaml +++ b/.github/workflows/goreleaser-snapshot-fleet.yaml @@ -57,7 +57,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 015a464b4b..98c9cd3a59 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -264,13 +264,13 @@ jobs: npm install -g fleetctl fleetctl config set --address ${{ needs.gen.outputs.address }} --token ${{ needs.login.outputs.token }} + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl diff --git a/.github/workflows/push-osquery-perf-to-ecr.yml b/.github/workflows/push-osquery-perf-to-ecr.yml deleted file mode 100644 index 0760d03900..0000000000 --- a/.github/workflows/push-osquery-perf-to-ecr.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Build docker image and publish to ECR - -on: - workflow_dispatch: - inputs: - enroll_secret: - description: 'Enroll Secret' - required: true - url: - description: 'Fleet server URL' - required: true - host_count: - description: 'Amount of hosts to emulate' - required: true - default: 20 - tag: - description: 'docker image tag' - required: true - default: latest - -# 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: - build-docker: - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 - with: - egress-policy: audit - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@05b148adc31e091bafbaf404f745055d4d3bc9d2 # v1 - with: - aws-access-key-id: ${{ secrets.LOADTEST_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.LOADTEST_AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-2 - - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@2f9f10ea3fa2eed41ac443fee8bfbd059af2d0a4 # v1 - - - name: Build, tag, and push image to Amazon ECR - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: osquery-perf - IMAGE_TAG: ${{ github.event.inputs.tag }} - run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg ENROLL_SECRET=${{ github.event.inputs.enroll_secret }} --build-arg HOST_COUNT=${{ github.event.inputs.host_count }} --build-arg SERVER_URL=${{ github.event.inputs.url }} -f Dockerfile.osquery-perf . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG diff --git a/.github/workflows/release-fleetctl-docker-deps.yaml b/.github/workflows/release-fleetctl-docker-deps.yaml new file mode 100644 index 0000000000..c751655d93 --- /dev/null +++ b/.github/workflows/release-fleetctl-docker-deps.yaml @@ -0,0 +1,84 @@ +# Builds and releases to production the fleetdm/bomutils:latest and fleetdm/wix:latest +# docker images, which are the docker image dependencies of the fleetctl command. +# +# This is separate from Fleet releases because we only release +# fleetdm/bomutils and fleetdm/wix only if we add new dependencies +# or for security updates. +name: Release fleetctl docker dependencies + +on: + push: + tags: + - "fleetctl-docker-deps-*" + +# 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: + push_latest: + runs-on: ubuntu-latest + environment: Docker Hub + permissions: + contents: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + + - name: Login to Docker Hub + uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} + + - name: Build fleetdm/wix + run: make wix-docker + + - name: Build fleetdm/bomutils + run: make bomutils-docker + + # + # After fleetdm/wix and fleetdm/bomutils are built, + # let's smoke test pkg/msi generation before pushing. + # + + - name: Install Go Dependencies + run: make deps-go + + - name: Build fleetctl + run: make fleetctl + + - name: Build MSI + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 + + - name: Build PKG + run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 + + # + # Now push to production + # + + - name: Push fleetdm/bomutils to docker hub + run: docker push fleetdm/bomutils:latest + + - name: Push fleetdm/wix to docker hub + run: docker push fleetdm/wix:latest diff --git a/.github/workflows/release-fleetd-base.yml b/.github/workflows/release-fleetd-base.yml index d7b02cfcf7..9909901964 100644 --- a/.github/workflows/release-fleetd-base.yml +++ b/.github/workflows/release-fleetd-base.yml @@ -51,16 +51,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 - with: - go-version: ${{ vars.GO_VERSION }} - - name: Checkout Code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version-file: 'go.mod' + - name: Check for fleetd component updates id: check-for-fleetd-component-updates run: | diff --git a/.github/workflows/test-bulk-operations-dashboard-changes.yml b/.github/workflows/test-bulk-operations-dashboard-changes.yml new file mode 100644 index 0000000000..cfbb26205d --- /dev/null +++ b/.github/workflows/test-bulk-operations-dashboard-changes.yml @@ -0,0 +1,60 @@ +name: Test bulk operations dashboard changes + +on: + pull_request: + paths: + - 'ee/bulk-operations-dashboard/**' + - '.github/workflows/test-bulk-operations-dashboard-changes.yml' + +# 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 + +permissions: + contents: read + +jobs: + build: + permissions: + contents: read + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x] + + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + # Set the Node.js version + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3.8.1 + with: + node-version: ${{ matrix.node-version }} + + + # Now start building! + # > …but first, get a little crazy for a sec and delete the top-level package.json file + # > i.e. the one used by the Fleet server. This is because require() in node will go + # > hunting in ancestral directories for missing dependencies, and since some of the + # > bundled transpiler tasks sniff for package availability using require(), this trips + # > up when it encounters another Node universe in the parent directory. + - run: rm -rf package.json package-lock.json node_modules/ + # > Turns out there's a similar issue with how eslint plugins are looked up, so we + # > delete the top level .eslintrc file too. + - run: rm -f .eslintrc.js + + # Get dependencies (including dev deps) + - run: cd ee/bulk-operations-dashboard/ && npm install + + # Run sanity checks + - run: cd ee/bulk-operations-dashboard/ && npm test + + # Compile assets + - run: cd ee/bulk-operations-dashboard/ && npm run build-for-prod diff --git a/.github/workflows/test-db-changes.yml b/.github/workflows/test-db-changes.yml index 301645008e..2bd89ab82b 100644 --- a/.github/workflows/test-db-changes.yml +++ b/.github/workflows/test-db-changes.yml @@ -10,7 +10,7 @@ on: paths: - '**.go' - 'server/datastore/mysql/schema.sql' - - '.github/workflows/test-schema-changes.yml' + - '.github/workflows/test-db-changes.yml' workflow_dispatch: # Manual # This allows a subsequently queued workflow run to interrupt previous runs @@ -35,18 +35,28 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Start Infra Dependencies # Use & to background this - run: docker-compose up -d mysql_test & + run: docker compose up -d mysql_test & + + - name: Wait for mysql + run: | + echo "waiting for mysql..." + until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + echo "." + sleep 1 + done + echo "mysql is ready" - name: Verify test schema changes run: | diff --git a/.github/workflows/test-fleetd-chrome.yml b/.github/workflows/test-fleetd-chrome.yml index 47ba496ebb..8cbb0125f9 100644 --- a/.github/workflows/test-fleetd-chrome.yml +++ b/.github/workflows/test-fleetd-chrome.yml @@ -66,7 +66,8 @@ jobs: npm test - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 # v3.1.1 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: + token: ${{ secrets.CODECOV_TOKEN }} directory: ./ee/fleetd-chrome/coverage flags: fleetd-chrome diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index 0f128b8025..1066843684 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -44,8 +44,7 @@ jobs: matrix: suite: ["integration", "core"] os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] - mysql: ["mysql:8.0.36"] + mysql: ["mysql:8.0.36", "mysql:8.4.2"] # make sure to update supported versions docs when this changes continue-on-error: ${{ matrix.suite == 'integration' }} # Since integration tests have a higher chance of failing, often for unrelated reasons, we don't want to fail the whole job if they fail runs-on: ${{ matrix.os }} @@ -59,18 +58,18 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ matrix.go-version }} - - name: Checkout Code uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + # Pre-starting dependencies here means they are ready to go when we need them. - name: Start Infra Dependencies # Use & to background this - run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker-compose -f docker-compose.yml -f docker-compose-redis-cluster.yml up -d mysql_test mysql_replica_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio saml_idp mailhog mailpit smtp4dev_test & + run: FLEET_MYSQL_IMAGE=${{ matrix.mysql }} docker compose -f docker-compose.yml -f docker-compose-redis-cluster.yml up -d mysql_test mysql_replica_test redis redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup minio saml_idp mailhog mailpit smtp4dev_test & - name: Add TLS certificate for SMTP Tests run: | @@ -98,13 +97,13 @@ jobs: - name: Wait for mysql run: | echo "waiting for mysql..." - until docker-compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do echo "." sleep 1 done echo "mysql is ready" echo "waiting for mysql replica..." - until docker-compose exec -T mysql_replica_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + until docker compose exec -T mysql_replica_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do echo "." sleep 1 done @@ -119,7 +118,6 @@ jobs: else RUN_TESTS_ARG='' fi - GO_TEST_EXTRA_FLAGS="-v -race=$RACE_ENABLED -timeout=$GO_TEST_TIMEOUT $RUN_TESTS_ARG" \ TEST_LOCK_FILE_PATH=$(pwd)/lock \ NETWORK_TEST=1 \ @@ -132,13 +130,17 @@ jobs: NETWORK_TEST_GITHUB_TOKEN=${{ secrets.FLEET_RELEASE_GITHUB_PAT }} \ make test-go 2>&1 | tee /tmp/gotest.log - # note: it's fine to upload multiple reports (one per matrix combination) - # for the same run, see https://docs.codecov.com/docs/merging-reports - - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 + - name: Create mysql identifier without colon + if: always() + run: | + echo "MATRIX_MYSQL_ID=$(echo ${{ matrix.mysql }} | tr -d ':')" >> $GITHUB_ENV + + - name: Save coverage + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: - files: coverage.txt - flags: backend + name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-coverage + path: ./coverage.txt + if-no-files-found: error - name: Generate summary of errors if: failure() @@ -156,10 +158,6 @@ jobs: fi GO_FAIL_SUMMARY=$GO_FAIL_SUMMARY envsubst < .github/workflows/config/slack_payload_template.json > ./payload.json - # TODO: figure out a sane way to combine outputs from different matrix jobs - # into a single slack notification, instead of sending one per job. This - # problem already existed but now it's accentuated because we're running 4 - # jobs. - name: Slack Notification if: github.event.schedule == '0 4 * * *' && failure() uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0 @@ -174,15 +172,32 @@ jobs: - name: Upload test log if: always() - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: - name: test-log + name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-test-log path: /tmp/gotest.log if-no-files-found: error - name: Upload summary test log if: always() - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: - name: summary-test-log + name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-summary-test-log path: /tmp/summary.txt + + # We upload all backend coverage in one step so that we're less like to end up in a situation with a partial coverage report. + upload-coverage: + needs: [test-go] + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Download artifacts + uses: actions/download-artifact@9c19ed7fe5d278cd354c7dfd5d3b88589c7e2395 # v4.1.6 + with: + pattern: '*-coverage' + - name: Upload to Codecov + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: backend diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 9d63523737..15b4fd05ce 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -69,8 +69,9 @@ jobs: yarn test:ci - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: + token: ${{ secrets.CODECOV_TOKEN }} flags: frontend lint-js: diff --git a/.github/workflows/test-native-tooling-packaging.yml b/.github/workflows/test-native-tooling-packaging.yml index db242cee0c..45a6e9abff 100644 --- a/.github/workflows/test-native-tooling-packaging.yml +++ b/.github/workflows/test-native-tooling-packaging.yml @@ -1,4 +1,4 @@ -# This workflow tests packaging of Fleet-osquery with the +# This workflow tests generation of fleetd packages with the # `fleetdm/fleetctl` Docker image. name: Test native tooling packaging @@ -21,6 +21,8 @@ on: - 'tools/bomutils-docker/**' - '.github/workflows/test-native-tooling-packaging.yml' workflow_dispatch: # Manual + schedule: + - cron: "0 5 * * *" # This allows a subsequently queued workflow run to interrupt previous runs concurrency: @@ -41,7 +43,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] + # build_type == 'remote' means this job will test the fleetdm/fleetctl:latest from Docker Hub. + # build_type == 'local' means this job will build the the image locally. + # + # TODO(lucas): We should only run 'remote' on schedule + # (adding conditionals to 'matrix' requires many tricks). + build_type: ["remote", "local"] runs-on: ${{ matrix.os }} steps: @@ -50,18 +57,30 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ matrix.go-version }} - - name: Checkout Code + if: ${{ matrix.build_type == 'local' }} uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go + if: ${{ matrix.build_type == 'local' }} + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Install Go Dependencies + if: ${{ matrix.build_type == 'local' }} run: make deps-go + - name: Build fleetdm/wix + if: ${{ matrix.build_type == 'local' }} + run: make wix-docker + + - name: Build fleetdm/bomutils + if: ${{ matrix.build_type == 'local' }} + run: make bomutils-docker + - name: Build fleetdm/fleetctl + if: ${{ matrix.build_type == 'local' }} run: make fleetctl-docker - name: Build DEB @@ -87,3 +106,24 @@ jobs: - name: Build PKG with Fleet Desktop run: docker run -v "$(pwd):/build" fleetdm/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + + - name: Slack Notification + if: github.event.schedule == '0 5 * * *' && failure() + uses: slackapi/slack-github-action@e28cf165c92ffef168d23c5c9000cffc8a25e117 # v1.24.0 + with: + payload: | + { + "text": "${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head.html_url }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "⚠️ Tests on fleetdm/fleetctl docker image failed.\nhttps://github.com/fleetdm/fleet/actions/runs/${{ github.run_id }}" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.github/workflows/test-packaging-build-docker-deps.yml b/.github/workflows/test-packaging-build-docker-deps.yml new file mode 100644 index 0000000000..d2f0cf7f95 --- /dev/null +++ b/.github/workflows/test-packaging-build-docker-deps.yml @@ -0,0 +1,94 @@ +# This workflow tests packaging of fleetd with the +# `fleetctl package` command using locally built fleetdm/wix and fleetdm/bomutils images. +# +# It fetches the targets: orbit, osquery and fleet-desktop from the default +# (Fleet's) TUF server, https://tuf.fleetctl.com. +name: Test packaging with local fleetdm/wix and fleetdm/bomutils + +on: + push: + branches: + - main + - patch-* + - prepare-* + paths: + - "tools/bomutils-docker/**" + - "tools/wix-docker/**" + - ".github/workflows/test-packaging-build-docker-deps.yml" + pull_request: + paths: + - "tools/bomutils-docker/**" + - "tools/wix-docker/**" + - ".github/workflows/test-packaging-build-docker-deps.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 + +jobs: + test-packaging: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: "go.mod" + + - name: Install Go Dependencies + run: make deps-go + + - name: Build fleetctl + run: make fleetctl + + - name: Build fleetdm/wix + run: make wix-docker + + - name: Build fleetdm/bomutils + run: make bomutils-docker + + - name: Build DEB + run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 + + - name: Build DEB with Fleet Desktop + run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + + - name: Build RPM + run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 + + - name: Build RPM with Fleet Desktop + run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + + - name: Build MSI + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 + + - name: Build MSI with Fleet Desktop + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + + - name: Build PKG + run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 + + - name: Build PKG with Fleet Desktop + run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop diff --git a/.github/workflows/test-packaging.yml b/.github/workflows/test-packaging.yml index 7190314cb9..0d8ff6d9b0 100644 --- a/.github/workflows/test-packaging.yml +++ b/.github/workflows/test-packaging.yml @@ -1,7 +1,8 @@ -# This workflow tests packaging of Fleet-osquery with the -# `fleetctl package` command. It fetches the targets: orbit, -# osquery and fleet-desktop from the default (Fleet's) TUF server, -# https://tuf.fleetctl.com. +# This workflow tests packaging of fleetd with the +# `fleetctl package` command. +# +# It fetches the targets: orbit, osquery and fleet-desktop from the default +# (Fleet's) TUF server, https://tuf.fleetctl.com. name: Test packaging on: @@ -47,81 +48,89 @@ jobs: # `macos-latest` uses arm64 by default now, so please be careful when # updating this version. os: [ubuntu-latest, macos-13] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit - - name: Harden Runner - uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 - with: - egress-policy: audit + - name: Run Colima + if: startsWith(matrix.os, 'macos') + timeout-minutes: 15 + # notes: + # - docker to install the docker CLI and interact with the Colima + # container runtime + # - colima is pre-installed in macos-12 runners, but not in macos-13 or + # macos-14 runners + run: | + brew install docker + # The runners come with an old version of python@3.12 that fails to upgrade + # when python gets pulled in as a dep through the chain + # colima -> lima -> qemu -> glibc -> python@3.12 + # Force upgrade it for now, remove once the problem is fixed + brew install --overwrite python@3.12 + brew install colima + colima start --mount $TMPDIR:w - - name: Pull fleetdm/wix - # Run in background while other steps complete to speed up the workflow - run: docker pull fleetdm/wix:latest & + - name: Pull fleetdm/wix + # Run in background while other steps complete to speed up the workflow + run: docker pull fleetdm/wix:latest - - name: Run Colima - if: startsWith(matrix.os, 'macos') - timeout-minutes: 10 - # notes: - # - docker to install the docker CLI and interact with the Colima - # container runtime - # - colima is pre-installed in macos-12 runners, but not in macos-13 or - # macos-14 runners - run: | - brew install docker colima - colima start --mount $TMPDIR:w + - name: Pull fleetdm/bomutils + # Run in background while other steps complete to speed up the workflow + run: docker pull fleetdm/bomutils:latest - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ matrix.go-version }} + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: "go.mod" - - name: Install wine and wix - if: startsWith(matrix.os, 'macos') - run: | - ./scripts/macos-install-wine.sh -n - wget https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip -nv -O wix.zip - mkdir wix - unzip wix.zip -d wix - rm -f wix.zip - echo wix installed at $(pwd)/wix + - name: Install wine and wix + if: startsWith(matrix.os, 'macos') + run: | + ./scripts/macos-install-wine.sh -n + wget https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip -nv -O wix.zip + mkdir wix + unzip wix.zip -d wix + rm -f wix.zip + echo wix installed at $(pwd)/wix - # It seems faster not to cache Go dependencies - - name: Install Go Dependencies - run: make deps-go + # It seems faster not to cache Go dependencies + - name: Install Go Dependencies + run: make deps-go - - name: Build fleetctl - run: make fleetctl + - name: Build fleetctl + run: make fleetctl - - name: Build DEB - run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 + - name: Build DEB + run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 - - name: Build DEB with Fleet Desktop - run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + - name: Build DEB with Fleet Desktop + run: ./build/fleetctl package --type deb --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop - - name: Build RPM - run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 + - name: Build RPM + run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 - - name: Build RPM with Fleet Desktop - run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + - name: Build RPM with Fleet Desktop + run: ./build/fleetctl package --type rpm --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop - - name: Build MSI - run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 + - name: Build MSI + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 - - name: Build MSI with Fleet Desktop - run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + - name: Build MSI with Fleet Desktop + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop - - name: Build PKG - run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 + - name: Build PKG + run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 - - name: Build PKG with Fleet Desktop - run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + - name: Build PKG with Fleet Desktop + run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop - - name: Build MSI (using local Wix) - if: startsWith(matrix.os, 'macos') - run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop --local-wix-dir ./wix + - name: Build MSI (using local Wix) + if: startsWith(matrix.os, 'macos') + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop --local-wix-dir ./wix diff --git a/.github/workflows/test-yml-specs.yml b/.github/workflows/test-yml-specs.yml index 75e46d6af0..fe8f3ecace 100644 --- a/.github/workflows/test-yml-specs.yml +++ b/.github/workflows/test-yml-specs.yml @@ -33,7 +33,6 @@ jobs: strategy: matrix: os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -42,13 +41,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Run apply spec tests run: | diff --git a/.vscode/launch.json b/.vscode/launch.json index 3e3ac95d6a..885f407fb8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -61,6 +61,21 @@ "--dev_license" ] }, + { + "name": "Fleet vuln_processing (licensed)", + "type": "go", + "request": "launch", + "mode": "auto", + "buildFlags": "-tags='full,fts5'", + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/cmd/fleet", + "args": [ + "vuln_processing", + "--dev", + "--logging_debug", + "--dev_license", + ] + }, { "name": "Attach to a running Fleet server", "type": "go", diff --git a/16538-preserve-manage-query-automations-modal-state b/16538-preserve-manage-query-automations-modal-state deleted file mode 100644 index d8ea5ca981..0000000000 --- a/16538-preserve-manage-query-automations-modal-state +++ /dev/null @@ -1,2 +0,0 @@ -- Fix a bug where the manage query automations modal would lose its state when the user clicks - "Preview data" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd42253b8..2e87456657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,174 @@ +## Fleet 4.56.0 (Sep 7, 2024) + +### Endpoint operations + +- Added index to `query_results` DB table to speed up finding last query timestamp for a given query and host. +- Added a link in the UI to the error message when a CSR can't be downloaded due to missing private key. +- Added a disabled overlay to the Other Workflows modal on the policy page. +- Improved performance of live queries to accommodate for higher volumes when utilizing zero-trust workflows. +- Improved `fleetctl` gitops error message when trying to change team name to a team that already exists. + +### Device management + +- Added server support for multiple VPP tokens. +- Added new endpoints and updated existing endpoints for managing multiple Apple Business Manager tokens. +- Added support for S3 to store MDM bootstrap packages (uses the same bucket configuration as for software installers). +- Added support to UI for self service VPP software. +- Added backend and gitops support for self service VPP. +- Added ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. +- Added an offline screen to the macOS MDM migration flow. +- Added new ABM page to Fleet UI. +- Added new VPP page to the fleet UI +- Added support to track the Apple Business Manager "terms expired" API error per token, as well as a global flag that gets set as soon as one token has its terms expired. +- Updated the instructions on "My device" for MDM migrations on pre-Sonoma macOS hosts. +- Updated to allow multiple teams to be assigned to the same VPP Token. +- Updated process so that deleting installed software or VPP app now makes it available for re-installation. +- Updated to enforce minimum OS version settings during Apple Automated Device Enrollment (ADE). +- Updated ABM ingestion so that deleted iOS/iPadOS host will continue to report to Fleet as long as host is in Apple Business Manager (ABM). +- Updated so that refetching an offline iOS/iPadOS host will not add new MDM commands to the queue if previous refetch has not completed yet. +- Updated UI so that downloading a software installer package now shows the browser's built-in progress bar. +- Updated relevant documentation to include references to multiple ABM and VPP tokens. +- Consolidated Automatic Enrollment and VPP settings under the MDM settings integration page. +- Cleared apps associated with a VPP token if it's moved off of a team. + +### Vulnerability management + +- Added ALAS bulletins as vulnerability source for Amazon Linux (instead of OVAL for Amazon Linux 2, and adds support for Amazon Linux 1, 2022, and 2023). +- Added matching rules for July and August Microsoft 365 security updates (https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates). +- Added the following filters to `/software/titles` and `/software/versions` API endpoints: `exploit: bool`, `min_cvss_score: float`, `max_cvss_score: float`. +- Updated software titles/versions tables to allow for filtering by vulnerabilities including severity and known exploit. +- Updated to use empty CVE description when the NVD CVE feed doesn't include description entries (instead of panicking). +- Updated matching software that is not installed by Fleet so that it shows up as 'Available for install' on host details page. +- Updated base images of `fleetdm/fleetctl`, `fleetdm/bomutils` and `fleetdm/wix` to fix critical vulnerabilities found by Trivy. +- Updated vulnerability scanning to use `macos` SW target for CPEs of homebrew packages. +- Updated vulnerability scanning to not ignore software with non-ASCII en dash and em dash characters. +- Updated `GET /api/v1/fleet/vulnerabilities/{cve}` endpoint to add validation of CVE format, and a 204 response. The 204 response indicates that the vulnerability is known to Fleet but not present on any hosts. +- Updated the UI to add new empty states for searching vulnerabilities: invalid CVE format searched, a known CVE serached but not present on hosts, not a known CVE searched, exploited vulnerability empty state, operating systems empty state, new icons. + +### Bug fixes and improvements + +- Added support for MySQL 8.4.2 LTS. +- Updated Go to go1.22.6. +- Updated Fleet server to now accept arguments via stdin. This is useful for passing secrets that you don't want to expose as env vars, in the command line, or in the config file. +- Updated text for "Turn on MDM" banners in UI. +- Updated ABM host tooltip copy on the manage host page to clarify when host vitals will be available to view. +- Updated copy on auotmatic enrollment modal on my device page. +- Updated host details activities tooltip and empty state copy to reflect recently added capabilities. +- Updated Fleet Free so users see a Premium feature message when clicking to add software. +- Updated usage reporting to report statistics on new AI features, maintenance window, and `fleetd`. +- Fixed bug where configuration profile was still showing the old label name after the name was updated. +- Fixed a bug when a cached prepared statement gets deleted in the MySQL server itself without Fleet knowing. +- Fixed a bug where the wrong API path was used to download a software installer. +- Fixed the failing_host_count so it is never 0. This count is normally updated once an hour during cleanups_then_aggregation cron job. +- Fixed CVE-2024-4030 in Vulncheck feed incorrectly targeting non-Windows hosts. +- Fixed a bug where the "Self-service" filter for the list of software and the list of host's software did not take App Store apps into account. +- Fixed a bug where the "My device" page in Fleet Desktop did not show the self-service software tab when App Store apps were available as self-install. +- Fixed a bug where a software installer (a package or a VPP app) that has been installed on a host still shows up as "Available for install" and can still be requested to be installed after the host is transferred to a different team without that installer (or after the installer is deleted). +- Fixed the "Available for install" filter in the host's software page so that installers that were requested to be installed on the host (regardless of installation status) also show up in the list. +- Fixed UI popup messages bleeding off viewport in some cases. +- Fixed an issue with the scheduling of cron jobs at startup if the job has never run, which caused it to be delayed. +- Fixed UI to display the label names in case-insensitive alphabetical order. + +## Fleet 4.55.2 (Sep 05, 2024) + +### Bug fixes + +- Removed validation of APNS certificate from server startup. This was no longer necessary because we now allow for APNS certificates to be renewed in the UI. +- Fixed logic to properly catch and log APNs errors. + +## Fleet 4.55.1 (Aug 15, 2024) + +### Bug fixes + +- Added a disabled overlay to the Other Workflows modal on the policy page. +- Updated text for "Turn on MDM" banners in UI. +- Fixed a bug when a cached prepared statement got deleted in the MySQL server itself without Fleet knowing. +- Continued with an empty CVE description when the NVD CVE feed didn't include description entries (instead of panicking). +- Scheduled maintenance events are now scheduled over calendar events marked "Free" (not busy) in Google Calendar. +- Fixed a bug where the wrong API path was used to download a software installer. +- Improved fleetctl gitops error message when trying to change team name to a team that already exists. +- Updated ABM (Apple Business Manager) host tooltip copy on the manage host page to clarify when host vitals will be available to view. +- Added index to query_results DB table to speed up finding the last query timestamp for a given query and host. +- Displayed the label names in case-insensitive alphabetical order in the fleet UI. + +## Fleet 4.55.0 (Aug 8, 2024) + +**NOTE:** Beginning with v4.55.0, Fleet no longer supports MySQL 5.7 because it has reached [end of life](https://mattermost.com/blog/mysql-5-7-reached-eol-upgrade-to-mysql-8-x-today/#:~:text=In%20October%202023%2C%20MySQL%205.7,to%20upgrade%20to%20MySQL%208.). The minimum version supported is MySQL 8.0.36. + +### Endpoint Operations + +- Added support for generating `fleetd` packages for Linux ARM64. +- Added new `fleetctl package` --arch flag. +- Updated `fleetctl package` command to remove the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. +- Updated maintenance window descriptions to update regularly to match the failing policy description/resolution. +- Updated maintenance windows using Google Calendar so that calendar events are now recreated within 30 seconds if deleted or moved to the past. + - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes. + - **NOTE:** These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods. + +### Device Management + +- Integrated [Escrow Buddy](https://github.com/macadmins/escrow-buddy) to add enforcement of FileVault during the MacOS Setup Assistant process for hosts that are +enrolled into teams (or no team) with disk encryption turned on. Thank you [homebysix](https://github.com/homebysix) and team! +- Updated `fleetd` to use [Escrow Buddy](https://github.com/macadmins/escrow-buddy) to rotate FileVault keys. Removed or modified internal API endpoints documented in the API for contributors. +- Added OS updates support to iOS/iPadOS devices. +- Added iOS and iPadOS device details refetch triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint. +- Added iOS and iPadOS user-installed apps to Fleet. +- Added iOS and iPadOS apps to be installed using Apple's VPP (Volume Purchase Program) to Fleet. +- Added support for VPP to GitOps. +- Added the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and `GET /vpp` endpoints and related functionality. +- Added new `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints and associated functionality. +- Added the associated VPP apps to the `GET /software/titles` and `GET /software/titles/:id` endpoints. +- Added the associated VPP apps to the `GET /hosts/:id/software` and `GET /device/:token/software` endpoints. +- Added support to delete a VPP app from a team in `DELETE /software/titles/:software_title_id/available_for_install`. +- Added `exclude_software` query parameter to "Get host by identifier" API. +- Added ability to add/remove/disable apps with VPP in the Fleet UI. +- Added a warning banner to the UI if the uploaded VPP token is about to expire/has expired. +- Added UI updates for VPP feature on host software and my device pages. +- Added global activity support for VPP-related activities. +- Added UI features for managing VPP apps for iPadOS and iOS hosts. +- Updated profile activities to include iOS and iPadOS. +- Updated Fleet UI to show OS version compliance on host details page. +- Added support for "No teams" on all software pages including adding software installers. +- Added DB migration to support VPP software features. +- Added DB migration to migrate older team configurations to the new version that includes both installers and App Store apps. +- Linux lock/unlock scripts now make use of pam_nologin to keep AD users locked out. +- Installed software list now includes Linux .deb packages that are 'on hold'. +- Added a special-case to properly name the Notion .exe Windows installer the same as how it will be reported by osquery post-install. +- Increased threshold to renew Apple SCEP certificates for MDM enrollments to 180 days. + +### Vulnerability Management + +- Fixed CVEs identified as 'Rejected' in NVD not matching against software. +- Fixed false negative vulnerabilities with IntelliJ IDEA CE and PyCharm CE installed via Homebrew. + +### Bug fixes and improvements + +- Dropped support for MySQL 5.7 and raised minimum required to MySQL 8.0.36. +- Updated software pre-install to use new GitOps format for query. +- Updated UI tooltips for pending OS settings. +- Fixed a styling issue in the controls > OS settings > disk encryption table. +- Fixed a bug in `fleetctl preview` that was causing it to fail if Docker was installed without support for the deprecated `docker-compose` CLI. +- Fixed an issue where the app-wide warning banners were not showing on the initial page load. +- Fixed a bug where the hosts page would sometimes allow excess pagination. +- Fixed a bug where software install results could not be retrieved for deleted hosts in the activity feed. +- Fixed path that was incorrect for the download software installer package endpoint `GET /software/titles/:software_title_id/package`. +- Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set. +- Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. +- Fixed a bug where software install results could not be retrieved for deleted hosts in the activity feed. +- Fixed a bug where a software installer (a package or a VPP app) that has been installed on a host still shows up as "Available for install" and can still be requested to be installed after the host is transferred to a different team without that installer (or after the installer is deleted). + ## Fleet 4.54.1 (Jul 24, 2024) ### Bug fixes -* Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. -* Implemented a small refactor of orbit subsystems. -* Removed the `--version` flag from the `fleetctl package` command. The version of the package can now be controlled by the `--orbit-channel` flag. -* Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . -* In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. -* Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. -* Re-enabled cached logins after windows Unlock. + +- Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. +- Implemented a small refactor of orbit subsystems. +- Removed the `--version` flag from the `fleetctl package` command. The version of the package can now be controlled by the `--orbit-channel` flag. +- Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . +- In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. +- Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. +- Re-enabled cached logins after windows Unlock. + ## Fleet 4.54.0 (Jul 17, 2024) ### Endpoint Operations @@ -93,19 +254,19 @@ ### Bug fixes -* Updated fleetctl get queries/labels/hosts descriptions. -* Fixed exporting CSVs with fields that contain commas to render properly. -* Fixed link to fleetd uninstall instructions in "Delete device" modal. -* Rendered only one banner on the my device page based on priority order. -* Hidden query delete checkboxes from team observers. -* Fixed issue where the Fleet UI could not be used to renew the ABM token after the ABM user who created the token was deleted. -* Fixed an issue where special characters in HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall broke the "installer_utils.ps1 -uninstallOrbit" step in the Windows MSI installer. -* Fixed counts for hosts with low disk space in summary page. -* Fleet UI fixes: Hide CTA on inherited queries/policies from team level users. -* Updated software updated timestamp tooltip. -* Fixed issue where some Windows applications were getting matched against Windows OS vulnerabilities. -* Fixed crash in `fleetd` installer on Windows if there are registry keys with special characters on the system. -* Fixed UI capitalizations. +- Updated fleetctl get queries/labels/hosts descriptions. +- Fixed exporting CSVs with fields that contain commas to render properly. +- Fixed link to fleetd uninstall instructions in "Delete device" modal. +- Rendered only one banner on the my device page based on priority order. +- Hidden query delete checkboxes from team observers. +- Fixed issue where the Fleet UI could not be used to renew the ABM token after the ABM user who created the token was deleted. +- Fixed an issue where special characters in HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall broke the "installer_utils.ps1 -uninstallOrbit" step in the Windows MSI installer. +- Fixed counts for hosts with low disk space in summary page. +- Fleet UI fixes: Hide CTA on inherited queries/policies from team level users. +- Updated software updated timestamp tooltip. +- Fixed issue where some Windows applications were getting matched against Windows OS vulnerabilities. +- Fixed crash in `fleetd` installer on Windows if there are registry keys with special characters on the system. +- Fixed UI capitalizations. ## Fleet 4.53.0 (Jun 25, 2024) diff --git a/CODEOWNERS b/CODEOWNERS index a1102215a4..153161e7ee 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,19 +39,9 @@ go.sum @fleetdm/go go.mod @fleetdm/go /cmd/ @fleetdm/go -/orbit/ @lucasmrod @getvictor @roperzh @gillespi314 /server/ @fleetdm/go -/server/service/handler.go @lucasmrod @getvictor @roperzh @gillespi314 -/server/mdm/ @roperzh @gillespi314 @lucasmrod @georgekarrv -/server/worker/ @lucasmrod @getvictor @roperzh @gillespi314 -/server/vulnerabilities/ @lucasmrod @mostlikelee @getvictor -/server/cron/ @getvictor @lucasmrod @roperzh @mostlikelee -/ee/fleetd-chrome @lucasmrod @getvictor @RachelElysia -/ee/vulnerability-dashboard @eashaw -/ee/cis @sharon-fdm @lucasmrod @RachelElysia @jacobshandling -/ee/server/calendar @lucasmrod @getvictor @jacobshandling -/ee/server/service @roperzh @gillespi314 @lucasmrod @getvictor -/scripts/mdm @roperzh @gillespi314 @jahzielv @dantecatalfamo +/ee/server/ @fleetdm/go +/orbit/ @lucasmrod @roperzh @lukeheath @georgekarrv @sharon-fdm ############################################################################################## # 🚀 React files and other files related to the core product frontend. @@ -66,9 +56,9 @@ go.mod @fleetdm/go # FUTURE: Look for a way to not have this notify every single person in this "github team". ############################################################################################## -/infrastructure/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv -/charts/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv -/terraform/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv +/infrastructure/ @rfairburn @ksatter @lukeheath @edwardsb @georgekarrv +/charts/ @rfairburn @ksatter @lukeheath @edwardsb @georgekarrv +/terraform/ @rfairburn @ksatter @lukeheath @edwardsb @georgekarrv /it-and-security/ @noahtalerman @lukeheath @spokanemac @getvictor ############################################################################################## @@ -76,8 +66,8 @@ go.mod @fleetdm/go # # (see website/config/custom.js for DRIs of other paths not listed here) ############################################################################################## -/docs @eashaw -/docs/REST\ API/rest-api.md @lukeheath # « REST API reference documentation +/docs @rachaelshaw @lukeheath +/docs/REST\ API/rest-api.md @rachaelshaw @lukeheath # « REST API reference documentation /docs/Contributing/API-for-contributors.md @lukeheath # « Advanced / contributors-only API reference documentation /schema @eashaw # « Data tables (osquery/fleetd schema) documentation /docs/Deploy/_kubernetes/ @dherder # « Kubernetes best practice @@ -105,13 +95,13 @@ go.mod @fleetdm/go /handbook/README.md @mikermcneil /handbook/company/open-positions.yml @sampfluger88 /handbook/company/product-groups.md @lukeheath -/handbook/business-operations/README.md @sampfluger88 -/handbook/business-operations/business-operations.rituals.yml @sampfluger88 -/handbook/business-operations/Application-security.md @lukeheath -/handbook/business-operations/security-audits.md @lukeheath -/handbook/business-operations/security-policies.md @lukeheath -/handbook/business-operations/security.md @lukeheath -/handbook/business-operations/vendor-questionnaires.md @lukeheath +/handbook/finance/README.md @sampfluger88 +/handbook/finance/finance.rituals.yml @sampfluger88 +/handbook/digital-experience/application-security.md @lukeheath +/handbook/digital-experience/security-audits.md @lukeheath +/handbook/digital-experience/security-policies.md @lukeheath +/handbook/digital-experience/security.md @lukeheath +/handbook/digital-experience/vendor-questionnaires.md @lukeheath /handbook/digital-experience @sampfluger88 /handbook/customer-success @sampfluger88 /handbook/demand @sampfluger88 @@ -137,43 +127,7 @@ go.mod @fleetdm/go ############################################################################################## # 🚀 GitHub workflows ############################################################################################## -/.github/workflows/README.md @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/goreleaser-fleet.yaml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/update-certs.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/codeql-analysis.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/codeql.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/scorecards-analysis.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/integration.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/fleetctl-preview.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/fleetctl-preview-latest.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/goreleaser-orbit.yaml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/trivy-scan.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/goreleaser-snapshot-fleet.yaml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/build-and-push-fleetctl-docker.yml @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/fleetd-tuf.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/generate-desktop-targets.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-yml-specs.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/build-binaries.yaml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/fleet-and-orbit.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/build-orbit.yaml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/generate-osqueryd-targets.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-packaging.yml @lucasmrod @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/release-helm.yaml @rfairburn @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/pr-helm.yaml @rfairburn @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/tfvalidate.yml @rfairburn @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/dogfood-deploy.yml @rfairburn @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-db-changes.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-go.yaml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/golangci-lint.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-native-tooling-packaging.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/check-tuf-timestamps.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-puppet.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/generate-nudge-targets.yml @roperzh @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-js.yml @ghernandez345 @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/dogfood-gitops.yml @getvictor @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/test-fleetd-chrome.yml @getvictor @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/release-fleetd-chrome.yml @getvictor @lukeheath @georgekarrv @sharon-fdm -/.github/workflows/release-fleetd-chrome-beta.yml @getvictor @lukeheath @georgekarrv @sharon-fdm +/.github/workflows/ @lukeheath @georgekarrv @sharon-fdm # ℹ️ But wait, there's more! # See the comments up top to learn where else DRIs and maintainers are configured. diff --git a/Dockerfile-desktop-linux b/Dockerfile-desktop-linux index b0ff52c050..c17d3894d0 100644 --- a/Dockerfile-desktop-linux +++ b/Dockerfile-desktop-linux @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 golang:1.22.4-bullseye@sha256:067c5c7fe6d79f900c5ebe8351166356d6e3bbfcc6f807030e89b9a929252273 +FROM --platform=linux/amd64 golang:1.23.1-bullseye@sha256:45b43371f21ec51276118e6806a22cbb0bca087ddd54c491fdc7149be01035d5 LABEL maintainer="Fleet Developers" RUN mkdir -p /usr/src/fleet diff --git a/Dockerfile.osquery-perf b/Dockerfile.osquery-perf deleted file mode 100644 index 89331d9312..0000000000 --- a/Dockerfile.osquery-perf +++ /dev/null @@ -1,16 +0,0 @@ -FROM golang:1.22.4-alpine3.20@sha256:ace6cc3fe58d0c7b12303c57afe6d6724851152df55e08057b43990b927ad5e8 - -ARG ENROLL_SECRET -ARG HOST_COUNT -ARG SERVER_URL - -ENV ENROLL_SECRET ${ENROLL_SECRET} -ENV HOST_COUNT ${HOST_COUNT} -ENV SERVER_URL ${SERVER_URL} - -COPY ./cmd/osquery-perf/agent.go ./go.mod ./go.sum ./cmd/osquery-perf/mac10.14.6.tmpl /osquery-perf/ -WORKDIR /osquery-perf/ -RUN go mod download -RUN go build -o osquery-perf - -CMD ./osquery-perf -enroll_secret $ENROLL_SECRET -host_count $HOST_COUNT -server_url $SERVER_URL diff --git a/Makefile b/Makefile index 2e7c317baa..9e63214ebd 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,7 @@ define HELP_TEXT make generate-go - Generate and bundle required go code make generate-js - Generate and bundle required js code make generate-dev - Generate and bundle required code in a watch loop + make generate-doc - Generate updated API documentation for activities, osquery flags make clean - Clean all build artifacts make clean-assets - Clean assets only @@ -221,6 +222,12 @@ docker-push-release: docker-build-release fleetctl-docker: xp-fleetctl docker build -t fleetdm/fleetctl --platform=linux/amd64 -f tools/fleetctl-docker/Dockerfile . +bomutils-docker: + cd tools/bomutils-docker && docker build -t fleetdm/bomutils --platform=linux/amd64 -f Dockerfile . + +wix-docker: + cd tools/wix-docker && docker build -t fleetdm/wix --platform=linux/amd64 -f Dockerfile . + .pre-binary-bundle: rm -rf build/binary-bundle mkdir -p build/binary-bundle/linux @@ -281,7 +288,7 @@ binary-arch: .pre-binary-arch .pre-binary-bundle .pre-fleet # Drop, create, and migrate the e2e test database e2e-reset-db: - docker-compose exec -T mysql_test bash -c 'echo "drop database if exists e2e; create database e2e;" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql_test bash -c 'echo "drop database if exists e2e; create database e2e;" | MYSQL_PWD=toor mysql -uroot' ./build/fleet prepare db --mysql_address=localhost:3307 --mysql_username=root --mysql_password=toor --mysql_database=e2e e2e-setup: @@ -312,7 +319,7 @@ e2e-serve-premium: e2e-reset-db # Usage: # make e2e-set-desktop-token host_id=1 token=foo e2e-set-desktop-token: - docker-compose exec -T mysql_test bash -c 'echo "INSERT INTO e2e.host_device_auth (host_id, token) VALUES ($(host_id), \"$(token)\") ON DUPLICATE KEY UPDATE token=VALUES(token)" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql_test bash -c 'echo "INSERT INTO e2e.host_device_auth (host_id, token) VALUES ($(host_id), \"$(token)\") ON DUPLICATE KEY UPDATE token=VALUES(token)" | MYSQL_PWD=toor mysql -uroot' changelog: sh -c "find changes -type f | grep -v .keep | xargs -I {} sh -c 'grep \"\S\" {}; echo' > new-CHANGELOG.md" @@ -347,7 +354,7 @@ fleetd-tuf: # Reset the development DB db-reset: - docker-compose exec -T mysql bash -c 'echo "drop database if exists fleet; create database fleet;" | MYSQL_PWD=toor mysql -uroot' + docker compose exec -T mysql bash -c 'echo "drop database if exists fleet; create database fleet;" | MYSQL_PWD=toor mysql -uroot' ./build/fleet prepare db --dev # Back up the development DB to file diff --git a/README.md b/README.md index 7e8651a618..3c23f067c3 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,6 @@ Fleet has no ambition to replace all of your other tools. (Though it might repl Fleet plays well with Munki, Chef, Puppet, and Ansible, as well as with security tools like Crowdstrike and SentinelOne. For example, you can use the free version of Fleet to quickly report on what hosts are _actually_ running your EDR agent. -While most folks prefer to use one or the other, Fleet can also coexist peacefully with Rapid7 and other agent-based vulnerability scanners. This can be useful during migrations. - #### Free as in free The free version of Fleet will [always be free](https://fleetdm.com/pricing). Fleet is [independently backed](https://linkedin.com/company/fleetdm) and actively maintained with the help of many amazing [contributors](https://github.com/fleetdm/fleet/graphs/contributors). diff --git a/docs/Using Fleet/Automations.md b/articles/automations.md similarity index 92% rename from docs/Using Fleet/Automations.md rename to articles/automations.md index e124fc779e..478b870556 100644 --- a/docs/Using Fleet/Automations.md +++ b/articles/automations.md @@ -40,6 +40,9 @@ Host status automations send a webhook request if a configured percentage of hos Fleet sends these webhook requests once per day by default. - + + + + + - diff --git a/docs/Using Fleet/enroll-chromebooks.md b/articles/chrome-os.md similarity index 85% rename from docs/Using Fleet/enroll-chromebooks.md rename to articles/chrome-os.md index 2c1684cc5f..d74dfb89c1 100644 --- a/docs/Using Fleet/enroll-chromebooks.md +++ b/articles/chrome-os.md @@ -1,8 +1,6 @@ # ChromeOS For visibility on ChromeOS hosts, Fleet provides the fleetd Chrome extension which provides similar functionality as osquery on other operating systems. -## Adding ChromeOS hosts to Fleet - To learn how to add ChromeOS hosts to Fleet, visit [here](https://fleetdm.com/docs/using-fleet/adding-hosts#enroll-chromebooks). > The fleetd Chrome browser extension is supported on ChromeOS operating systems that are managed using [Google Admin](https://admin.google.com). It is not intended for non-ChromeOS hosts with the Chrome browser installed. @@ -23,6 +21,10 @@ By default, the hostname for a Chromebook host will be blank. The hostname can b ## Debugging ChromeOS To learn how to debug the Fleetd Chrome extension, visit [here](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Testing-and-local-development.md#fleetd-chrome-extension). - - - + + + + + + + diff --git a/docs/Using Fleet/CIS-Benchmarks.md b/articles/cis-benchmarks.md similarity index 91% rename from docs/Using Fleet/CIS-Benchmarks.md rename to articles/cis-benchmarks.md index 9942eb0e3c..905d62efba 100644 --- a/docs/Using Fleet/CIS-Benchmarks.md +++ b/articles/cis-benchmarks.md @@ -11,7 +11,7 @@ Fleet has implemented native support for CIS Benchmarks for the following platfo - Windows 10 Enterprise - Windows 11 Enterprise -[Where possible](#limitations), each CIS Benchmark is implemented with a [policy query](./REST-API.md#policies) in Fleet. +[Where possible](#limitations), each CIS Benchmark is implemented with a [policy query](https://fleetdm.com/docs/rest-api/rest-api#policies) in Fleet. These benchmarks are intended to gauge your organization's security posture, rather than the current state of a given host. A host may fail a CIS Benchmark policy despite having the correct settings enabled if there is no configuration profile or Group Policy Object (GPO) in place to enforce the setting. For example, this is the query for **CIS - Ensure FileVault Is Enabled (MDM Required)**: @@ -95,7 +95,7 @@ Following are the requirements to use the CIS Benchmarks in Fleet: - Devices must be running [`fleetd`](https://fleetdm.com/docs/using-fleet/orbit), Fleet's lightweight agent. - Some CIS Benchmarks explicitly involve verifying MDM-based controls, so devices must be enrolled to an MDM solution. -- On macOS, the orbit component of fleetd must have "Full Disk Access", see [Grant Full Disk Access to Osquery on macOS](./Adding-hosts.md#grant-full-disk-access-to-osquery-on-macos). +- On macOS, the orbit component of fleetd must have "Full Disk Access", see [Grant Full Disk Access to Osquery on macOS](https://fleetdm.com/guides/enroll-hosts#grant-full-disk-access-to-osquery-on-macos). ## Limitations @@ -111,7 +111,9 @@ In August 2023, we completed scale testing on 10k Windows hosts and 70k macOS ho Detailed results are [here](https://docs.google.com/document/d/1OSpyzMkHjVhG_-EIBkLu7X3hj_XfVASGl3IXIYChpck/edit?usp=sharing). - - - - + + + + + + diff --git a/articles/configuring-default-teams-for-devices-in-fleet.md b/articles/configuring-default-teams-for-devices-in-fleet.md new file mode 100644 index 0000000000..1b22d16424 --- /dev/null +++ b/articles/configuring-default-teams-for-devices-in-fleet.md @@ -0,0 +1,46 @@ +# Configuring default teams for macOS, iOS, and iPadOS devices in Fleet + +Fleet allows you to configure default teams for macOS, iOS, and iPadOS devices as they automatically enroll in your instance. This ensures that devices are assigned to the correct teams and receive the appropriate apps and configuration profiles at enrollment. + +## Why configure default teams? + +The ability to assign default teams during device enrollment helps streamline the deployment process. Each device is automatically placed in its correct group, ensuring it receives the necessary configuration profiles and apps without requiring manual assignment. + +### Configuring default teams in Fleet + +Follow these steps to assign default teams to your devices: + +1. **Navigate to automatic enrollment settings**: + + - Go to **Settings > Integrations > Mobile device management (MDM)**, and locate the **Automatic enrollment** section. + +2. **Edit the ABM token**: + + - Click **Edit** next to the ABM token for which you want to configure default teams. + +3. **Assign default teams**: + + - In the modal, use the dropdowns to select the appropriate default team for each platform (macOS, iOS, and iPadOS). + +4. **Save your changes**: + + - After selecting the teams, click **Save** to apply the changes. New devices will be automatically assigned to the selected teams upon enrollment. + +## Benefits of configuring default teams + +1. **Streamlined deployment**: Devices are configured and ready for use immediately after enrollment, reducing manual setup time. + +2. **Reduced errors**: Automating team assignments helps avoid misconfigurations and ensures that the right profiles and apps are installed on the correct devices. + +## Conclusion + +Configuring default teams in Fleet simplifies the enrollment and management of Apple devices, ensuring that each device is assigned to the correct team immediately upon enrollment. This feature reduces manual setup tasks for IT teams by automating the assignment of configuration profiles and apps based on team specifications. By streamlining the deployment process and minimizing errors, configuring default teams ensures that devices are ready to use right out of the box, helping organizations save time and maintain consistency across their device fleet. + +For organizations managing a large number of macOS, iOS, or iPadOS devices, this feature plays a crucial role in automating routine tasks, increasing efficiency, and improving the overall deployment experience. It enables teams to focus on more critical tasks and be confident that newly enrolled devices are correctly configured. For more information on using Fleet, please refer to the [Fleet documentation](https://fleetdm.com/docs) and [guides](https://fleetdm.com/guides). + + + + + + + diff --git a/docs/Using Fleet/MDM-custom-OS-settings.md b/articles/custom-os-settings.md similarity index 84% rename from docs/Using Fleet/MDM-custom-OS-settings.md rename to articles/custom-os-settings.md index bcc30c022a..aadafc1f84 100644 --- a/docs/Using Fleet/MDM-custom-OS-settings.md +++ b/articles/custom-os-settings.md @@ -1,6 +1,6 @@ # Custom OS settings -In Fleet you can enforce OS settings on your your macOS, iOS, iPadOS, and Windows hosts using configuration profiles. +In Fleet you can enforce OS settings like security restrictions, screen lock, Wi-Fi etc., on your your macOS, iOS, iPadOS, and Windows hosts using configuration or device profiles. ## Enforce OS settings @@ -36,7 +36,9 @@ In the top box, with "Verified," "Verifying," "Pending," and "Failed" statuses, 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/articles/debunk-the-cross-platform-myth.md b/articles/debunk-the-cross-platform-myth.md new file mode 100644 index 0000000000..80d81c919c --- /dev/null +++ b/articles/debunk-the-cross-platform-myth.md @@ -0,0 +1,59 @@ +# Debunk the cross-platform myth + +Conventional wisdom holds that cross-platform device management is a nightmare. It’s no surprise—most solutions out there are cobbled together with bolted-on features that never quite mesh. If you’ve tried managing a mixed fleet of macOS, Windows, and Linux devices, you might have some scars to show for it. But here’s the thing: it doesn’t have to be that way. Fleet is built differently, and it’s time to debunk the myth that cross-platform management has to suck. + +## Cross-platform pain points + +The skepticism around cross-platform device management is real, and for good reason. Many IT teams have been burned by solutions that promise seamless management across different operating systems but deliver only frustration and complexity. Solutions that often leave a trail of disappointed admins in their wake, often forcing you to manage the tools more than the devices. Fleet flips that script by letting you interact directly with each operating system’s native features. Whether Apple’s macOS, Microsoft’s Windows, or various Linux distributions, Fleet provides a consistent management experience without forcing you to “talk Windows” to your Macs or vice versa. + + +## Managing every OS like it’s your favorite + +Fleet introduces familiar concepts like custom attributes and dynamic grouping but adapts them to work with the nuances of each operating system. This means you can manage your macOS, Windows, and Linux devices without juggling multiple management platforms or dealing with convoluted workarounds. Everything is streamlined in one open-source platform, giving you direct access to the data and events from each OS. + +By working directly with native operating system features, Fleet ensures you don’t lose low-level control or compromise on capabilities. Instead of managing multiple MDM solutions, you can focus on managing your devices—regardless of OS. + +For example: + +* **Operating systems**: You can enforce OS updates with Declarative Device Management (DDM), Nudge, and Windows Update from one console. +* **Automated enrollment**: Drop-ship devices to your end users with Apple Business Manager or Autopilot and let them set up their own accounts. No IT help is needed. +* **Config management**: Manage settings with configuration profiles for Apple and device profiles for Windows. Use a canary team to test changes before they go live. +* **App management**: Automatically keep applications and plugins secure and up-to-date. Install the software end users need or let them install it themselves via self-service. +* **Scripts and events**: Easily manage and version control your custom script library. Execute shell and PowerShell scripts when computers drift from the baseline. +* **Keep up with Apple**: Fleet's team and community stay current on the latest features and releases from all supported platform vendors, not just Apple. + +## Switching platforms is disruptive + +It’s understandable to be cautious about adopting a new management solution, especially if you’re concerned about the time and effort involved in switching. However, Fleet is designed with ease of transition in mind. Our platform integrates seamlessly with your existing tools and workflows, minimizing disruption. Plus, with our comprehensive documentation and responsive community support, you’ll have everything you need to get up and running quickly. Fleet’s flexible deployment options let you start small and scale at your pace, ensuring a smooth, controlled migration. + +![Migrate to Fleet dialog](../website/assets/images/articles/debunk-the-cross-platform-myth-600x521@2x.png "Migrate to Fleet dialog") + +## One platform, many possibilities + +Fleet isn’t just about making cross-platform management tolerable—it’s about making it genuinely effective. With Fleet, you can enforce OS updates, automate device enrollment, manage configurations, and keep applications secure, all from one place. You can also deploy Fleet yourself at any time; it’s 100% source-available, meaning you can look at the source code for how any part of it works. + +And because Fleet is open-source, it’s designed with flexibility and transparency in mind. You can tailor it to fit your organization’s needs, whether you’re managing a few hundred devices or tens of thousands. + +
+ +Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy. + +
+ +_Wes Whetstone, Staff CPE at Stripe_ + + +## The takeaway + +Cross-platform management doesn’t have to be the headache it’s been in the past. Fleet is here to simplify how you manage your devices, no matter what mix of operating systems you’re dealing with. It’s time to let go of the myth that managing different platforms means managing different tools. With Fleet, you can have everything you need in one place—without the anxiety. + +Ready to get started? + +Visit our [start page](https://fleetdm.com/start) to begin your journey. + + + + + + + diff --git a/articles/deploy-fleet-on-cloudgov.md b/articles/deploy-fleet-on-cloudgov.md index c075ab6c9b..18f0c238a8 100644 --- a/articles/deploy-fleet-on-cloudgov.md +++ b/articles/deploy-fleet-on-cloudgov.md @@ -1,7 +1,5 @@ # Deploy Fleet on Cloud.gov (Cloud Foundry) -> **This article was archived on May 16, 2024.** Check out [Deploy Fleet](https://fleetdm.com/docs/deploy/deploy-fleet) for the most up to date deployment method. - ![Deploy Fleet on Cloud.gov](../website/assets/images/articles/deploy-fleet-on-cloudgov-800x450@2x.png) Cloud.gov is a [FEDRAMP moderate Platform-as-a-Service diff --git a/articles/deploy-security-agents.md b/articles/deploy-security-agents.md new file mode 100644 index 0000000000..20d6cd28ab --- /dev/null +++ b/articles/deploy-security-agents.md @@ -0,0 +1,97 @@ +# Deploy security agents + +![Deploy security agents](../website/assets/images/articles/deploy-security-agents-1600x900@2x.png) + +Fleet [v4.50.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.50.0) introduced the ability to upload and deploy security agents to your hosts. Beyond a [bootstrap package](https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#bootstrap-package) at enrollment, deploying security agents allows you to specify and verify device configuration using a pre-enrollment osquery query and customization of the install and post-install scripts, allowing for key and license deployment and configuration. This guide will walk you through the steps to upload, configure, and install a security agent to hosts in your fleet. + +## Prerequisites + +* Fleet [v4.50.0](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.50.0). +* `fleetd` 1.25.0 deployed via MDM or built with the `--scripts-enabled` flag. +* An S3 bucket [configured](https://fleetdm.com/docs/configuration/fleet-server-configuration#s-3-software-installers-bucket) to store the installers. +* Increase any load balancer timeouts to at least 5 minutes for the following endpoints: + * [Add software](https://fleetdm.com/docs/rest-api/rest-api#add-software). + * [Batch-apply software](https://fleetdm.com/docs/rest-api/rest-api#add-software). + +## Step-by-step instructions + +### Access security agent installers + +To access and manage security agents in Fleet: + +* **Navigate to the Software page**: Click on the "Software" tab in the main navigation menu. +* **Select a team**: Click on the dropdown at the top left of the page. +* **Find your software**: using the filters on the top of the table, you can choose between: + * “Available for install” filters software that can be installed on your hosts. + * “Self-service” filters software that end users can install from Fleet Desktop. +* **Select security agent installer**: Click on a software package to view details and access additional actions for the agent installer. + +### Add a security agent to a team + +* **Navigate to the Software page**: Click on the "Software" tab in the main navigation menu. +* **Select a team**: Select a team or the "No team" team to add a security agent. + +> Security agents cannot be added to "All teams" + +* Click the “Add Software” button in the top right corner, and a modal will appear. +* Choose a file to upload. `.pkg`, `.msi`, `.exe`, or `.deb` files are supported. +* After selecting a file, a default install script will be pre-filled. If the security agent requires a custom installation process, this script can be edited. +* To allow users to install the software from Fleet Desktop, check the “Self-service” checkbox. +* To customize the conditions, click on “Advanced options”: + * **Pre-install condition**: A pre-install condition is a valid osquery SQL statement that will be evaluated on the host before installing the software. If provided, the installation will proceed only if the query returns any value. + * **Post-install script** A post-install script will run after the installation is complete, allowing you to configure the security agent right after installation. If this script returns a non-zero exit code, the installation will fail, and `fleetd` will attempt to uninstall the software. + +### Install a security agent on a host + +After an installer is added to a team, it can be installed on hosts via the UI. + +* **Navigate to the Hosts page**: Click on the "Hosts" tab in the main navigation menu. +* **Navigate to the Host details page**: Click the host you want to install the security agent. +* **Navigate to the Host software tab**: In the host details, search for the tab named “Software” +* **Find your security agent**: Use the search bar and filters to search for your security agent. +* **Install the security agent on the host**: In the leftmost row of the table, click on “Actions” > “Install.” +* **Track installation status**: by either + * Checking the “Install status” in the host software table. + * Navigate to the “Details” tab on the host details page and check the activity log. + +### Edit a security agent + +Security agent installers can’t be edited via the UI. To modify an installer, remove it from the UI and add a new one. + +### Remove a security agent from a team + +* **Navigate to the Software page**: Click on the "Software" tab in the main navigation menu. +* **Select a team**: Select a team or the "No team" team to add a security agent. +* **Find your software**: using the filters on the top of the table, you can choose between: + * “Available for install” filters software can be installed on your hosts. + * “Self-service” filters software that users can install from Fleet Desktop. +* **Select security agent installer**: Click on a software package to view details. +* **Remove security agent installer**: From the Actions menu, select "Delete." Click the "Delete" button on the modal. + +> Removing a security agent from a team will not uninstall the agent from the existing host(s). + +### Manage security agents with the REST API + +Fleet also provides a REST API for managing software programmatically. The API allows you to add, update, retrieve, list, and delete software. Detailed documentation on Fleet's [REST API is available](https://fleetdm.com/docs/rest-api/rest-api#software). + +### Manage security agents with GitOps + +Installers for security agents can be managed via `fleetctl` using [GitOps](https://fleetdm.com/docs/using-fleet/gitops). + +Please refer to the documentation specific to [managing software with GitOps](https://fleetdm.com/docs/using-fleet/gitops#software). For a real-world example, [see how we manage software at Fleet](https://github.com/fleetdm/fleet/tree/main/it-and-security/teams). + + +## Conclusion + +Deploying security agents with Fleet is straightforward and ensures your hosts are protected with the latest security measures. This guide has shown you how to access, add, and install security agents, as well as manage them using the REST API and `fleetctl`. Following these steps can effectively equip your fleet with the necessary security tools. + +See Fleet's [documentation](https://fleetdm.com/docs/using-fleet) and additional [guides](https://fleetdm.com/guides) for more details on advanced setups, software features, and vulnerability detection. + + + + + + + + + diff --git a/articles/discovering-chrome-ai-using-fleet.md b/articles/discovering-chrome-ai-using-fleet.md new file mode 100644 index 0000000000..3c39b370b3 --- /dev/null +++ b/articles/discovering-chrome-ai-using-fleet.md @@ -0,0 +1,69 @@ +# Discovering Chrome AI using Fleet + +![Discovering Chrome AI using Fleet](../website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg) + +# Discovering AI in Chrome with Fleet + +Staying ahead of technological innovations is crucial for individuals and organizations. Google Chrome, one of the most widely used web browsers, continually evolves to incorporate new features, including artificial intelligence (AI). This article will guide you through detecting if AI capabilities have been enabled in Chrome on macOS using Fleet. + +## Introduction to Chrome AI innovations + +Google Chrome has integrated AI to enhance user experience by providing intelligent suggestions, improving search results, and offering in-browser assistance. Visit the [Chrome AI Innovations page](https://www.google.com/chrome/ai-innovations/) for more information. + +## Using Fleet to discover AI features in Chrome + +Fleet, a comprehensive device management and security tool, allows organizations to monitor installed software configurations and enabled features on endpoints and servers. Investigating this data enables Fleet admins to build SQL queries for detection. + +### Step 1: Understanding Chrome's preferences JSON file + +On macOS, Chrome stores user settings and configurations in a JSON file at the following path: + +``` +/Users//Library/Application Support/Google/Chrome/Default/Preferences +``` + +### Step 2: Identifying AI-related settings + +Chrome AI-related preferences are stored in the `optimization_guide` section of the Chrome Preferences file. The `tab_organization_setting_state` key / value field will signify if AI features are enabled. + +`jq` is a lightweight and powerful command-line tool for parsing, filtering, and manipulating JSON data. It can extract and parse information from JSON files at specific key / value fields. + +In this case, `jq` is used to locate and read the value of the `tab_organization_setting_state` key within the Chrome Preferences file. This knowledge allows an admin to craft a Fleet query for reporting the state of the Chrome AI settings. + +- If enabled, the setting will return `1`. + +![Chrome settings UI with Chrome AI enabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png) + +``` +% jq '.optimization_guide.tab_organization_setting_state' /Users//Library/Application\ Support/Google/Chrome/Default/Preferences +1 +``` + +- If disabled, the setting will return `2`. + +![Chrome settings UI with Chrome AI disabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png) + +``` +% jq '.optimization_guide.tab_organization_setting_state' /Users//Library/Application\ Support/Google/Chrome/Default/Preferences +2 +``` + +### Step 3: Query the JSON file with Fleet + +To detect Chrome AI features in Fleet, use a SQL query like the following: + +``` +SELECT fullkey,path FROM parse_json WHERE path LIKE '/Users/%/Library/Application Support/Google/Chrome/Default/Preferences' AND fullkey='optimization_guide/tab_organization_setting_state'; +``` + +### Conclusion + +Fleet's powerful querying abilities allow you to monitor features like these across all of your devices. + + + + + + + + diff --git a/docs/Using Fleet/downgrading-fleet.md b/articles/downgrade-fleet.md similarity index 87% rename from docs/Using Fleet/downgrading-fleet.md rename to articles/downgrade-fleet.md index 34f8fd04ff..0874e27c66 100644 --- a/docs/Using Fleet/downgrading-fleet.md +++ b/articles/downgrade-fleet.md @@ -1,4 +1,4 @@ -# Downgrading from Fleet Premium +# Downgrade from Fleet Premium Follow these steps to downgrade your Fleet instance from Fleet Premium. @@ -34,8 +34,9 @@ Follow these steps to downgrade your Fleet instance from Fleet Premium. 1. Remove your license key from your Fleet configuration. Documentation on where the license key is located in your configuration is [here](https://fleetdm.com/docs/deploying/configuration#license). 2. Restart your Fleet server. - - - - - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/docs/Using Fleet/MDM-disk-encryption.md b/articles/enforce-disk-encryption.md similarity index 76% rename from docs/Using Fleet/MDM-disk-encryption.md rename to articles/enforce-disk-encryption.md index 8d8278763f..8dd2b6419f 100644 --- a/docs/Using Fleet/MDM-disk-encryption.md +++ b/articles/enforce-disk-encryption.md @@ -1,4 +1,4 @@ -# Disk encryption +# Enforce disk encryption _Available in Fleet Premium_ @@ -8,7 +8,9 @@ In Fleet, you can enforce disk encryption for your macOS and Windows hosts. When disk encryption is enforced, hosts’ disk encryption keys will be stored in Fleet. -For Windows hosts, disk encryption is enforced on the C: volume (default system/OS drive). +For macOS hosts that automatically enroll, disk encryption is enforced during Setup Assistant. + +For Windows, disk encryption is enforced on the C: volume (default system/OS drive). ## Enforce disk encryption @@ -54,15 +56,13 @@ How to view the disk encryption key: ## Migrate macOS hosts -When migrating macOS hosts another MDM solution, in order to complete the process of encrypting the hard drive and escrowing the key in Fleet, your end users must take action. +When migrating macOS hosts from another MDM solution, in order to complete the process of encrypting the hard drive and escrowing the key in Fleet, your end users must log out or restart their device. -If the host already had disk encryption turned on, the user will need to input their password. +Share [these guided instructions](https://fleetdm.com/guides/mdm-migration#how-to-turn-on-disk-encryption) with your end users. -If the host did not already have disk encryption turned on, the user will need to log out or restart their computer. - -Share [these guided instructions](./MDM-migration-guide.md#how-to-turn-on-disk-encryption) with your end users. - - - + + + + + - diff --git a/docs/Using Fleet/MDM-OS-updates.md b/articles/enforce-os-updates.md similarity index 56% rename from docs/Using Fleet/MDM-OS-updates.md rename to articles/enforce-os-updates.md index 6986593c76..de3fdbc83d 100644 --- a/docs/Using Fleet/MDM-OS-updates.md +++ b/articles/enforce-os-updates.md @@ -1,18 +1,14 @@ -# OS updates +# Enforce OS updates _Available in Fleet Premium_ -In Fleet you can enforce OS updates on your macOS and Windows hosts remotely. - -## Enforce OS updates - -You can enforce OS updates using the Fleet UI, Fleet API, or [Fleet's GitOps workflow](https://github.com/fleetdm/fleet-gitops). +In Fleet, you can enforce OS updates on your macOS, Windows, iOS, and iPadOS hosts remotely using the Fleet UI, Fleet API, or [Fleet's GitOps workflow](https://github.com/fleetdm/fleet-gitops). Fleet UI: 1. Head to the **Controls** > **OS updates** tab. -2. To enforce OS updates for macOS, select **macOS** and set a **Minimum version** and **Deadline**. +2. To enforce OS updates for macOS, iOS, or iPadOS, select the platform and set a **Minimum version** and **Deadline**. 3. For Windows, select **Windows** and set a **Deadline** and **Grace period**. @@ -22,21 +18,22 @@ Fleet API: API documentation is [here](https://fleetdm.com/docs/rest-api/rest-ap ### macOS -When a minimum version is enforced, the end users see a native macOS notification (DDM) once per day. Users can choose to update ahead of the deadline or schedule it for that night. 24 hours before the deadline, the notification appears hourly and ignores Do Not Disturb. One hour before the deadline, the notification appears every 30 minutes, and then every 10 minutes. +When a minimum version is enforced, the end users see a native macOS notification (DDM) once per day. Users can choose to update ahead of the deadline or schedule it for that night. 24 hours before the deadline, the notification appears hourly and ignores Do Not Disturb. One hour before the deadline, the notification appears every 30 minutes and then every 10 minutes. If the host was turned off when the deadline passed, the update will be scheduled an hour after it’s turned on. -### macOS (below version 14.0) +For macOS devices that use Automated Device Enrollment (ADE), if the device is below the specified minimum version, it will be required to update to the latest [available version](#available-macos-ios-and-ipados-versions) during ADE before device setup and enrollment can proceed. -End users are encouraged to update macOS (via [Nudge](https://github.com/macadmins/nudge)). +### iOS and iPadOS -![Nudge window](https://raw.githubusercontent.com/fleetdm/fleet/main/docs/images/nudge-window.png) +End users will see a notification in their Notification Center after the deadline when a minimum version is enforced. They can’t use their iPhone or iPad until the OS update is installed. -| | > 1 day before deadline | < 1 day before deadline | Past deadline | -| ------------------------------------ | ----------------------- | ----------------------- | --------------------- | -| Nudge window frequency | Once a day at 8pm GMT | Once every 2 hours | Immediately on login | -| End user can defer | ✅ | ✅ | ❌ | -| Nudge window is dismissible | ✅ | ✅ | ❌ | +For iOS and iPadOS devices that use Automated Device Enrollment (ADE), if the device is below the specified +minimum version, it will be required to update to the latest [available version](#available-macos-ios-and-ipados-versions) during ADE before device setup and enrollment can proceed. + +### Available macOS, iOS, and iPadOS versions + +The Apple Software Lookup Service (available at [https://gdmf.apple.com/v2/pmv](https://gdmf.apple.com/v2/pmv)) is the official resource for obtaining a list of publicly available updates, upgrades, and Rapid Security Responses. Make sure to use versions available in GDMF; otherwise, the update will not be scheduled. ### Windows @@ -50,7 +47,21 @@ If an end user was on vacation when the deadline passed, the end user is given a Fleet enforces OS updates for quality and feature updates. Read more about the types of Windows OS updates in the Microsoft documentation [here](https://learn.microsoft.com/en-us/windows/deployment/update/get-started-updates-channels-tools#types-of-updates). - - - - +### macOS (below version 14.0) + +End users are encouraged to update macOS (via [Nudge](https://github.com/macadmins/nudge)). + +![Nudge window](https://raw.githubusercontent.com/fleetdm/fleet/main/docs/images/nudge-window.png) + +| | > 1 day before deadline | < 1 day before deadline | Past deadline | +| ------------------------------------ | ----------------------- | ----------------------- | --------------------- | +| Nudge window frequency | Once a day at 8pm GMT | Once every 2 hours | Immediately on login | +| End user can defer | ✅ | ✅ | ❌ | +| Nudge window is dismissible | ✅ | ✅ | ❌ | + + + + + + + diff --git a/docs/Using Fleet/enroll-hosts.md b/articles/enroll-hosts.md similarity index 96% rename from docs/Using Fleet/enroll-hosts.md rename to articles/enroll-hosts.md index 6112eec51e..0635855596 100644 --- a/docs/Using Fleet/enroll-hosts.md +++ b/articles/enroll-hosts.md @@ -1,7 +1,5 @@ # Enroll hosts -## Introduction - Fleet gathers information from an [osquery](https://github.com/osquery/osquery) agent installed on each of your hosts. The recommended way to install osquery is using fleetd. You can enroll macOS, Windows or Linux hosts via the [CLI](#cli) or [UI](#ui). To learn how to enroll Chromebooks, see [Enroll Chromebooks](#enroll-chromebooks). @@ -14,9 +12,9 @@ Fleet supports the [latest version of osquery](https://github.com/osquery/osquer > You must have `fleetctl` installed. [Learn how to install `fleetctl`](https://fleetdm.com/docs/using-fleet/fleetctl-cli#installing-fleetctl). -The `fleetctl package` command is used to generate Fleet's agent (fleetd). +The `fleetctl package` command is used to generate Fleet's agent (fleetd) install package.. -The `--type` flag is used to specify the fleetd installer type: +The `--type` flag is used to specify the fleetd installer type. Note that Windows can only generate an MSI package: - macOS: .pkg - Windows: .msi - Linux: .deb or .rpm @@ -39,7 +37,7 @@ To generate Fleet's agent (fleetd) in Fleet UI: 1. Go to the **Hosts** page, and select **Add hosts**. 2. Select the tab for your desired platform (e.g. macOS). -3. A CLI command with all necessary flags will be generated. Copy and run the command with [fleetctl](https://fleetdm.com/docs/using-fleet/fleetctl-cli) installed. +3. A CLI command with all necessary flags to generate an install package will be generated. Copy and run the command with [fleetctl](https://fleetdm.com/docs/using-fleet/fleetctl-cli) installed. ### Enroll host to a specific team @@ -54,7 +52,7 @@ You can use your software management tool of choice to distribute Fleet's agent ### Fleet Desktop -[Fleet Desktop](./Fleet-desktop.md) is a menu bar icon available on macOS, Windows, and Linux that gives your end users visibility into the security posture of their machine. +[Fleet Desktop](https://fleetdm.com/guides/fleet-desktop) is a menu bar icon available on macOS, Windows, and Linux that gives your end users visibility into the security posture of their machine. You can include Fleet Desktop in Fleet's agent (fleetd) by including `--fleet-desktop` in the `fleetctl package` command. @@ -379,6 +377,9 @@ but can result in a large volume of error logs. In fleetd v1.15.1, we added an e Applying the environmental variable `"FLEETD_SILENCE_ENROLL_ERROR"=1` on a host will silence fleetd enrollment errors if a `--fleet-url` is not present. This variable is read at launch and will require a restart of the Orbit service if it is not set before installing `fleetd` v1.15.1. - + + + + + - diff --git a/articles/filtering-software-by-vulnerability.md b/articles/filtering-software-by-vulnerability.md new file mode 100644 index 0000000000..8c8326bbc6 --- /dev/null +++ b/articles/filtering-software-by-vulnerability.md @@ -0,0 +1,44 @@ +# Filtering software by vulnerability in Fleet + +![Filtering software by vulnerability in Fleet](../website/assets/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg) + +## Introduction + +Fleet has introduced a powerful new feature that allows you to filter software by its associated vulnerabilities, helping you prioritize patches more effectively. Whether you're managing hundreds or thousands of software titles, this feature makes it easier to identify and address the most critical vulnerabilities in your environment. + +This filtering capability is particularly useful in environments where patch management is critical to your security posture. By filtering software based on vulnerability severity and known exploits, you can first ensure that the most critical issues are addressed, enhancing your overall security strategy. + +## Prerequisites + +* Fleet version 4.56 or later +* Premium users have access to advanced filters by severity level and known exploited vulnerabilities + +### Filtering Software by Vulnerability + +1. **Navigate to the Software page**: In your Fleet dashboard, go to the **Software** tab. This will display a list of all the software detected in your environment. + +2. **Add filters**: Click on the **Add Filters** button. This will open options for filtering the software list based on specific criteria. + +3. **Choose severity level**: From the dropdown menu, select the **Severity level** of vulnerabilities you're interested in. This allows you to focus on software with the highest severity of vulnerabilities, such as "Critical" or "High." + +4. **Toggle "Has known exploit"**: You can refine your filter by toggling the **Has known exploit** option. This will filter the software list to show only those with vulnerabilities that have known exploits, enabling you to prioritize these for patching. + +5. **Review filtered results**: Once you've applied your filters, the software list will update to show only the software that meets your criteria. This filtered view will help you prioritize which software needs immediate attention in your patching strategy. + +### Using the REST API to filter software for vulnerabilities + +Fleet provides a REST API to filter software for vulnerabilities, allowing you to integrate this functionality into your automated workflows. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api#vulnerabilities). + +## Conclusion + +The new software filtering feature in Fleet makes it easier than ever to manage vulnerabilities in your software environment. You can better protect your organization from potential threats by prioritizing patches based on severity and known exploits. Explore the API capabilities to integrate this feature into your broader security workflows. + +For more tips and detailed guides, don’t forget to check out the Fleet [documentation](https://fleetdm.com/docs/get-started/why-fleet). + + + + + + + + diff --git a/articles/fleet-4.55.0.md b/articles/fleet-4.55.0.md new file mode 100644 index 0000000000..309e0e70df --- /dev/null +++ b/articles/fleet-4.55.0.md @@ -0,0 +1,132 @@ +# Fleet 4.55.0 | MySQL 8, arm64 support, FileVault improvements, VPP support. + +![Fleet 4.55.0](../website/assets/images/articles/fleet-4.55.0-1600x900@2x.png) + +Fleet 4.55.0 is live. Check out the full [changelog](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.55.0) or continue reading to get the highlights. +For upgrade instructions, see our [upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs. + +## Highlights + +* MySQL 8 support, MySQL 5.7 sunsets +* FileVault key rotation with Escrow Buddy +* FileVault enforcement at enrollment +* Arm64 support +* VPP app support for macOS +* "No team" software support + +### MySQL 8 support, MySQL 5.7 sunsets + +Fleet has updated its database compatibility by adding support for MySQL 8, while simultaneously dropping support for MySQL 5.7. This change aligns Fleet with the latest advancements in database technology, offering enhanced performance, security, and features available in MySQL 8. Organizations using Fleet are encouraged to upgrade their database systems to MySQL 8 to take full advantage of these improvements. By focusing on the latest supported versions, Fleet ensures that its platform remains robust, secure, and well-equipped to handle the demands of modern IT environments while phasing out older versions that may not provide the same level of performance or security. + +### FileVault key rotation with Escrow Buddy + +Fleet now includes support for FileVault key rotation using [Escrow Buddy](https://github.com/macadmins/escrow-buddy), a tool developed by the Netflix Client Systems Engineering team for the MacAdmins community to securely manage and rotate FileVault recovery keys on macOS devices. This feature allows IT administrators to automate the process of rotating FileVault keys, ensuring that encrypted macOS hosts remain secure while maintaining access control. By integrating with Escrow Buddy, Fleet enables seamless key management, reducing the administrative burden of manually rotating keys and enhancing the overall security posture of macOS environments. This update reflects Fleet's commitment to providing robust security tools that integrate with trusted community resources, ensuring organizations can efficiently manage device encryption and recovery processes. + +### FileVault enforcement at enrollment + +Fleet now supports enforcing FileVault encryption during the enrollment process for macOS devices, ensuring that all newly enrolled Macs are automatically encrypted. This feature enhances security by mandating that FileVault is enabled as part of the initial device setup, reducing the risk of unencrypted data on managed endpoints. By integrating FileVault enforcement into the enrollment workflow, Fleet helps organizations maintain a consistent security posture across their macOS fleet, ensuring compliance with internal policies and regulatory requirements. This update underscores Fleet's commitment to providing comprehensive security management tools that protect sensitive data and simplify the administration of macOS devices. + +### Arm64 support + +Fleet now includes support for Linux hosts running on the arm64 architecture. This update enables organizations to integrate a broader range of devices into their Fleet management system, ensuring comprehensive oversight and control across diverse hardware environments. By supporting arm64 Linux hosts, Fleet caters to the growing use of ARM-based systems in various sectors, allowing IT administrators to manage these devices with the same level of detail and efficiency as traditional x86-based hosts. This aligns with Fleet's commitment to providing versatile and inclusive device management solutions, empowering users to maintain a unified and efficient IT infrastructure. + +### VPP app support for macOS + +Fleet now supports installing Volume Purchase Program (VPP) apps from the Apple App Store on macOS devices. This feature enables IT administrators to deploy and manage apps purchased through Apple's VPP directly to macOS hosts, streamlining the process of distributing essential software across the organization. By integrating VPP app installations into Fleet, organizations can ensure that licensed applications are efficiently deployed to the appropriate devices, improving software management and compliance. This update enhances Fleet's capabilities in managing macOS environments, offering a more seamless and centralized approach to app distribution for enterprise and educational settings. + +### "No team" software support + +Fleet now supports adding software to the "No team" team, providing greater flexibility in managing software across an organization's devices. This feature allows administrators to deploy and manage software that applies universally without being restricted to specific teams. By adding software to the "No team" team, IT teams can ensure that essential tools and applications are available across all devices, regardless of their team assignment. This update simplifies the management of widely used software and enhances the ability to maintain consistency and compliance across the entire fleet. It reflects Fleet's commitment to offering versatile solutions that cater to diverse organizational needs and streamline device management processes. + +## Changes + +**NOTE:** Beginning with v4.55.0, Fleet no longer supports MySQL 5.7 because it has reached [end of life](https://mattermost.com/blog/mysql-5-7-reached-eol-upgrade-to-mysql-8-x-today/#:~:text=In%20October%202023%2C%20MySQL%205.7,to%20upgrade%20to%20MySQL%208.). The minimum version supported is MySQL 8.0.36. + +### Endpoint Operations + +- Added support for generating `fleetd` packages for Linux ARM64. +- Added new `fleetctl package` --arch flag. +- Updated `fleetctl package` command to remove the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. +- Updated maintenance window descriptions to update regularly to match the failing policy description/resolution. +- Updated maintenance windows using Google Calendar so that calendar events are now recreated within 30 seconds if deleted or moved to the past. + - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes. + - **NOTE:** These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods. + +### Device Management + +- Integrated [Escrow Buddy](https://github.com/macadmins/escrow-buddy) to add enforcement of FileVault during the MacOS Setup Assistant process for hosts that are +enrolled into teams (or no team) with disk encryption turned on. Thank you homebysix and team! +- Added OS updates support to iOS/iPadOS devices. +- Added iOS and iPadOS device details refetch triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint. +- Added iOS and iPadOS user-installed apps to Fleet. +- Added iOS and iPadOS apps to be installed using Apple's VPP (Volume Purchase Program) to Fleet. +- Added support for VPP to GitOps. +- Added the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and `GET /vpp` endpoints and related functionality. +- Added new `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints and associated functionality. +- Added the associated VPP apps to the `GET /software/titles` and `GET /software/titles/:id` endpoints. +- Added the associated VPP apps to the `GET /hosts/:id/software` and `GET /device/:token/software` endpoints. +- Added support to delete a VPP app from a team in `DELETE /software/titles/:software_title_id/available_for_install`. +- Added `exclude_software` query parameter to "Get host by identifier" API. +- Added ability to add/remove/disable apps with VPP in the Fleet UI. +- Added a warning banner to the UI if the uploaded VPP token is about to expire/has expired. +- Added UI updates for VPP feature on host software and my device pages. +- Added global activity support for VPP-related activities. +- Added UI features for managing VPP apps for iPadOS and iOS hosts. +- Updated profile activities to include iOS and iPadOS. +- Updated Fleet UI to show OS version compliance on host details page. +- Added support for "No teams" on all software pages including adding software installers. +- Added DB migration to support VPP software features. +- Added DB migration to migrate older team configurations to the new version that includes both installers and App Store apps. +- Linux lock/unlock scripts now make use of pam_nologin to keep AD users locked out. +- Installed software list now includes Linux .deb packages that are 'on hold'. +- Added a special-case to properly name the Notion .exe Windows installer the same as how it will be reported by osquery post-install. +- Increased threshold to renew Apple SCEP certificates for MDM enrollments to 180 days. + +### Vulnerability Management + +- Fixed CVEs identified as 'Rejected' in NVD not matching against software. +- Fixed false negative vulnerabilities with IntelliJ IDEA CE and PyCharm CE installed via Homebrew. + +### Bug fixes and improvements + +- Dropped support for MySQL 5.7 and raised minimum required to MySQL 8.0.36. +- Updated software pre-install to use new GitOps format for query. +- Updated UI tooltips for pending OS settings. +- Added a migration to migrate older team configurations to the new version that includes both installers and App Store apps. +- Fixed a styling issue in the controls > OS settings > disk encryption table. +- Fixed a bug in `fleetctl preview` that was causing it to fail if Docker was installed without support for the deprecated `docker-compose` CLI. +- Fixed an issue where the app-wide warning banners were not showing on the initial page load. +- Fixed a bug where the hosts page would sometimes allow excess pagination. +- Fixed a bug where software install results could not be retrieved for deleted hosts in the activity feed. +- Fixed path that was incorrect for the download software installer package endpoint `GET /software/titles/:software_title_id/package`. +- Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set. +- Fixed the "Available for install" filter in the host's software page so that installers that were requested to be installed on the host (regardless of installation status) also show up in the list. +- Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. +- Fixed a bug in `fleetctl preview` that was causing it to fail if Docker was installed without support for the deprecated `docker-compose` CLI. +- Fixed a bug where software install results could not be retrieved for deleted hosts in the activity feed. +- Fixed a bug where a software installer (a package or a VPP app) that has been installed on a host still shows up as "Available for install" and can still be requested to be installed after the host is transferred to a different team without that installer (or after the installer is deleted). +- Fixed the "Available for install" filter in the host's software page so that installers that were requested to be installed on the host (regardless of installation status) also show up in the list. + +## Fleet 4.54.1 (Jul 24, 2024) + +### Bug fixes +- Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. +- Implemented a small refactor of orbit subsystems. +- Removed the `--version` flag from the `fleetctl package` command. The version of the package can now be controlled by the `--orbit-channel` flag. +- Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . +- In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. +- Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. +- Re-enabled cached logins after windows Unlock. + + + +## Ready to upgrade? + +Visit our [Upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs for instructions on updating to Fleet 4.55.0. + + + + + + + diff --git a/articles/fleet-4.56.0.md b/articles/fleet-4.56.0.md new file mode 100644 index 0000000000..14089ad9d6 --- /dev/null +++ b/articles/fleet-4.56.0.md @@ -0,0 +1,153 @@ +# Fleet 4.56.0 | Enhanced MDM migration, Exact CVE Search, and Self-Service VPP Apps. + +![Fleet 4.56.0](../website/assets/images/articles/fleet-4.56.0-1600x900@2x.png) + +Fleet 4.56.0 is live. Check out the full [changelog](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.56.0) or continue reading to get the highlights. +For upgrade instructions, see our [upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs. + +## Highlights +* Improved end-user MDM migration +* Enforce minimum OS version for MDM enrollment +* Exact match CVE search +* Software vulnerabilities severity filter +* Self-service VPP apps +* Multiple ABM and VPP support + + +### Improved end-user MDM migration + +Fleet has improved the end-user MDM migration workflow on macOS by enabling the migration of hosts manually enrolled in a third-party MDM over to Fleet MDM using the Fleet Desktop application. Previously, this capability was limited to hosts enrolled through Apple's Automated Device Enrollment (ADE), but with this update, manually enrolled hosts can now be seamlessly migrated to Fleet MDM. This feature is specifically available for macOS Sonoma devices (macOS 14 or greater). It makes the migration process more flexible and accessible for organizations looking to centralize their MDM management under Fleet. This enhancement simplifies the transition to Fleet MDM for a broader range of macOS devices, ensuring that all hosts can be managed consistently and securely. + + +### Enforce minimum OS version for MDM enrollment + +Fleet now enforces a minimum operating system (OS) requirement for macOS devices before they can be enrolled into Fleet's MDM. This feature ensures that only devices running a specified minimum macOS version can be enrolled, helping organizations maintain a consistent security and compliance baseline across their fleet. By setting a minimum OS requirement, Fleet prevents older, potentially less secure macOS versions from being managed under its MDM, thereby reducing vulnerabilities and ensuring all enrolled devices meet the organization's standards. This update enhances Fleet's ability to enforce security policies from the outset, ensuring that all devices in the fleet are up-to-date and capable of supporting the latest security and management features. + + +### Exact match CVE search + +Fleet has enhanced its CVE (Common Vulnerabilities and Exposures) search functionality by introducing exact match searching, allowing users to quickly and accurately find specific vulnerabilities across their fleet. This improvement ensures that security teams can pinpoint the exact CVE they are investigating without sifting through irrelevant results, streamlining the vulnerability management process. Additionally, Fleet provides better context in cases where no results are found, helping users understand why a particular CVE might not be present in their environment. This update improves the overall user experience in vulnerability management, making it easier to maintain security and compliance across all managed devices. + + +### Software vulnerabilities severity filter + +Fleet has introduced improved filtering capabilities for vulnerable software, allowing users to filter vulnerabilities by severity level. This enhancement enables security teams to prioritize their response efforts by focusing on the most critical vulnerabilities, ensuring that the highest-risk issues are promptly addressed. By providing a straightforward and efficient way to filter vulnerable software based on severity, Fleet helps organizations streamline their vulnerability management processes, reducing the risk of security incidents. This update aligns with Fleet's commitment to providing powerful tools that enhance the efficiency and effectiveness of security operations across all managed devices. + + +### Self-Service Apple App Store apps + +Fleet enables organizations to assign and install Apple App Store apps purchased through the Volume Purchase Program (VPP) directly via Self-Service using Fleet Desktop. This new feature allows IT administrators to make VPP-purchased apps available to end users seamlessly and flexibly. By integrating VPP app distribution into the Fleet Desktop Self-Service portal, organizations can streamline the deployment of essential software across their macOS devices, ensuring that users have easy access to the tools they need while maintaining control over software distribution. This update enhances the overall user experience and operational efficiency, empowering end users to install approved applications with minimal IT intervention. + + +### Multiple Apple Business Manager and VPP support + +Fleet now enables administrators to add and manage multiple Apple Business Manager (ABM) and Volume Purchase Program (VPP) tokens within a single Fleet instance. This feature is designed for both Managed Service Providers (MSPs) and large enterprises, allowing them to create separate automatic enrollment and App Store app workflows for different clients or divisions, each with their own ABM and VPP tokens. Whether you’re managing devices for multiple customers or supporting large organizations with distinct divisions, this update simplifies the process of handling macOS, iOS, and iPadOS devices. With support for multiple ABM and VPP connections, Fleet streamlines software and device management across varied environments, providing a scalable solution for both MSPs and enterprises looking to centralize control while maintaining flexibility for different user groups. + + +## Changes + +**NOTE:** Beginning with Fleet v4.55.0, Fleet no longer supports MySQL 5.7 because it has reached [end of life](https://mattermost.com/blog/mysql-5-7-reached-eol-upgrade-to-mysql-8-x-today/#:~:text=In%20October%202023%2C%20MySQL%205.7,to%20upgrade%20to%20MySQL%208.). The minimum version supported is MySQL 8.0.36. + +## Fleet 4.56.0 (Sep 7, 2024) + +### Endpoint operations + +- Added index to `query_results` DB table to speed up finding last query timestamp for a given query and host. +- Added a link in the UI to the error message when a CSR can't be downloaded due to missing private key. +- Added a disabled overlay to the Other Workflows modal on the policy page. +- Improved performance of live queries to accommodate for higher volumes when utilizing zero-trust workflows. +- Improved `fleetctl` gitops error message when trying to change team name to a team that already exists. + +### Device management + +- Added server support for multiple VPP tokens. +- Added new endpoints and updated existing endpoints for managing multiple Apple Business Manager tokens. +- Added support for S3 to store MDM bootstrap packages (uses the same bucket configuration as for software installers). +- Added support to UI for self service VPP software. +- Added backend and gitops support for self service VPP. +- Added ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. +- Added an offline screen to the macOS MDM migration flow. +- Added new ABM page to Fleet UI. +- Added new VPP page to the fleet UI +- Added support to track the Apple Business Manager "terms expired" API error per token, as well as a global flag that gets set as soon as one token has its terms expired. +- Updated the instructions on "My device" for MDM migrations on pre-Sonoma macOS hosts. +- Updated to allow multiple teams to be assigned to the same VPP Token. +- Updated process so that deleting installed software or VPP app now makes it available for re-installation. +- Updated to enforce minimum OS version settings during Apple Automated Device Enrollment (ADE). +- Updated ABM ingestion so that deleted iOS/iPadOS host will continue to report to Fleet as long as host is in Apple Business Manager (ABM). +- Updated so that refetching an offline iOS/iPadOS host will not add new MDM commands to the queue if previous refetch has not completed yet. +- Updated UI so that downloading a software installer package now shows the browser's built-in progress bar. +- Updated relevant documentation to include references to multiple ABM and VPP tokens. +- Consolidated Automatic Enrollment and VPP settings under the MDM settings integration page. +- Cleared apps associated with a VPP token if it's moved off of a team. + +### Vulnerability management + +- Added ALAS bulletins as vulnerability source for Amazon Linux (instead of OVAL for Amazon Linux 2, and adds support for Amazon Linux 1, 2022, and 2023). +- Added matching rules for July and August Microsoft 365 security updates (https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates). +- Added the following filters to `/software/titles` and `/software/versions` API endpoints: `exploit: bool`, `min_cvss_score: float`, `max_cvss_score: float`. +- Updated software titles/versions tables to allow for filtering by vulnerabilities including severity and known exploit. +- Updated to use empty CVE description when the NVD CVE feed doesn't include description entries (instead of panicking). +- Updated matching software that is not installed by Fleet so that it shows up as 'Available for install' on host details page. +- Updated base images of `fleetdm/fleetctl`, `fleetdm/bomutils` and `fleetdm/wix` to fix critical vulnerabilities found by Trivy. +- Updated vulnerability scanning to use `macos` SW target for CPEs of homebrew packages. +- Updated vulnerability scanning to not ignore software with non-ASCII en dash and em dash characters. +- Updated `GET /api/v1/fleet/vulnerabilities/{cve}` endpoint to add validation of CVE format, and a 204 response. The 204 response indicates that the vulnerability is known to Fleet but not present on any hosts. +- Updated the UI to add new empty states for searching vulnerabilities: invalid CVE format searched, a known CVE serached but not present on hosts, not a known CVE searched, exploited vulnerability empty state, operating systems empty state, new icons. + +### Bug fixes and improvements + +- Added support for MySQL 8.4.2 LTS. +- Updated Go to go1.22.6. +- Updated Fleet server to now accept arguments via stdin. This is useful for passing secrets that you don't want to expose as env vars, in the command line, or in the config file. +- Updated text for "Turn on MDM" banners in UI. +- Updated ABM host tooltip copy on the manage host page to clarify when host vitals will be available to view. +- Updated copy on auotmatic enrollment modal on my device page. +- Updated host details activities tooltip and empty state copy to reflect recently added capabilities. +- Updated Fleet Free so users see a Premium feature message when clicking to add software. +- Updated usage reporting to report statistics on new AI features, maintenance window, and `fleetd`. +- Fixed bug where configuration profile was still showing the old label name after the name was updated. +- Fixed a bug when a cached prepared statement gets deleted in the MySQL server itself without Fleet knowing. +- Fixed a bug where the wrong API path was used to download a software installer. +- Fixed the failing_host_count so it is never 0. This count is normally updated once an hour during cleanups_then_aggregation cron job. +- Fixed CVE-2024-4030 in Vulncheck feed incorrectly targeting non-Windows hosts. +- Fixed a bug where the "Self-service" filter for the list of software and the list of host's software did not take App Store apps into account. +- Fixed a bug where the "My device" page in Fleet Desktop did not show the self-service software tab when App Store apps were available as self-install. +- Fixed a bug where a software installer (a package or a VPP app) that has been installed on a host still shows up as "Available for install" and can still be requested to be installed after the host is transferred to a different team without that installer (or after the installer is deleted). +- Fixed the "Available for install" filter in the host's software page so that installers that were requested to be installed on the host (regardless of installation status) also show up in the list. +- Fixed UI popup messages bleeding off viewport in some cases. +- Fixed an issue with the scheduling of cron jobs at startup if the job has never run, which caused it to be delayed. +- Fixed UI to display the label names in case-insensitive alphabetical order. + +## Fleet 4.55.2 (Sep 05, 2024) + +### Bug fixes + +* Removed validation of APNS certificate from server startup. This was no longer necessary because we now allow for APNS certificates to be renewed in the UI. +* Fixed logic to properly catch and log APNs errors. + +## Fleet 4.55.1 (Aug 14, 2024) + +### Bug fixes + +* Added a disabled overlay to the Other Workflows modal on the policy page. +* Updated text for "Turn on MDM" banners in UI. +* Fixed a bug when a cached prepared statement got deleted in the MySQL server itself without Fleet knowing. +* Continued with an empty CVE description when the NVD CVE feed didn't include description entries (instead of panicking). +* Scheduled maintenance events are now scheduled over calendar events marked "Free" (not busy) in Google Calendar. +* Fixed a bug where the wrong API path was used to download a software installer. +* Improved fleetctl gitops error message when trying to change team name to a team that already exists. +* Updated ABM (Apple Business Manager) host tooltip copy on the manage host page to clarify when host vitals will be available to view. +* Added index to query_results DB table to speed up finding the last query timestamp for a given query and host. +* Displayed the label names in case-insensitive alphabetical order in the fleet UI. + +## Ready to upgrade? + +Visit our [Upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs for instructions on updating to Fleet 4.56.0. + + + + + + + diff --git a/docs/Using Fleet/Fleet-desktop.md b/articles/fleet-desktop.md similarity index 84% rename from docs/Using Fleet/Fleet-desktop.md rename to articles/fleet-desktop.md index b4696943db..c0b1b0e574 100644 --- a/docs/Using Fleet/Fleet-desktop.md +++ b/articles/fleet-desktop.md @@ -1,12 +1,7 @@ # Fleet Desktop -- [Installing Fleet Desktop](#installing-fleet-desktop) -- [Upgrading Fleet Desktop](#upgrading-fleet-desktop) -- [Custom Transparency Link](#custom-transparency-link) -- [Securing Fleet Desktop](#securing-fleet-desktop) -Fleet Desktop is a menu bar icon available on macOS, Windows, and Linux. +Fleet Desktop is a menu bar icon available on macOS, Windows, and Linux that gives your end users visibility into the security posture of their machine. This unlocks two key benefits: -At its core, Fleet Desktop gives your end users visibility into the security posture of their machine. This unlocks two key benefits: * Self-remediation: end users can see which policies they are failing and resolution steps, reducing the need for IT and security teams to intervene * Scope transparency: end users can see what the Fleet agent can do on their machines, eliminating ambiguity between end users and their IT and security teams @@ -16,10 +11,10 @@ At its core, Fleet Desktop gives your end users visibility into the security pos -## Installing Fleet Desktop +## Install Fleet Desktop For information on how to install Fleet Desktop, visit: [Adding Hosts](https://fleetdm.com/docs/using-fleet/adding-hosts#fleet-desktop). -## Upgrading Fleet Desktop +## Upgrade Fleet Desktop Once installed, Fleet Desktop will be automatically updated via Fleetd. To learn more, visit: [Self-managed agent updates](https://fleetdm.com/docs/deploying/fleetctl-agent-updates#self-managed-agent-updates). ## Custom transparency link @@ -32,7 +27,7 @@ On the settings page, go to "Organization Settings" and select "Fleet Desktop." For information on how to set the custom transparency link via a YAML configuration file, see the [configuration files](https://fleetdm.com/docs/configuration/fleet-server-configuration#fleet-desktop-settings) documentation. -## Securing Fleet Desktop +## Secure Fleet Desktop Requests sent by Fleet Desktop and the web page that opens when clicking on the "My Device" tray item use a [Random (Version 4) UUID](https://www.rfc-editor.org/rfc/rfc4122.html#section-4.4) token to uniquely identify each host. @@ -57,7 +52,9 @@ As a consequence, Fleet Desktop will issue a new token if the current token is: This change is imperceptible to users, as clicking on the "My device" tray item always uses a valid token. If a user visits an address with an expired token, they will get a message instructing them to click on the tray item again. - - + + + + + - diff --git a/articles/fleet-in-your-calendar-introducing-maintenance-windows.md b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md index 534b993835..79ef934847 100644 --- a/articles/fleet-in-your-calendar-introducing-maintenance-windows.md +++ b/articles/fleet-in-your-calendar-introducing-maintenance-windows.md @@ -21,7 +21,7 @@ Fleet provides AI-generated explanations directly in the calendar events, detail ## _Maintenance windows_ include: * **Personalized scheduling:** Updates are timed based on individual calendar events, so interventions happen when they are least intrusive. -* **Automatic rescheduling:** If a scheduled update becomes impractical—due to changes in your calendar, for example—Fleet automatically finds a new appropriate time. +* **Rescheduling flexibility:** If a scheduled update becomes impractical for any reason, users have the option to manually move the maintenance window to a more suitable time. We suggest rescheduling within one week to ensure timely updates. * **Enhanced compliance:** With auto-scheduled maintenance windows, compliance with security protocols is maintained effortlessly, ensuring all devices are up to date without manual intervention. _Maintenance windows_ is a direct response to common challenges faced in workplace productivity, particularly unplanned disruptions from essential updates. Fleet aims to support smoother, more efficient work environments by incorporating user feedback and addressing these long-standing issues. diff --git a/docs/Using Fleet/Usage-statistics.md b/articles/fleet-usage-statistics.md similarity index 88% rename from docs/Using Fleet/Usage-statistics.md rename to articles/fleet-usage-statistics.md index 28413ee174..1db24222de 100644 --- a/docs/Using Fleet/Usage-statistics.md +++ b/articles/fleet-usage-statistics.md @@ -1,7 +1,9 @@ -# Usage statistics +# Fleet usage statistics Fleet Device Management Inc. periodically collects information about your instance. +> To disable usage statistics, [see here](#disable-usage-statistics). + ## What is included in usage statistics in Fleet? Below is the JSON payload that is sent to Fleet Device Management Inc: @@ -34,6 +36,11 @@ Below is the JSON payload that is sent to Fleet Device Management Inc: "numHostSoftwareInstalledPaths": 999, "numSoftwareCPEs": 999, "numSoftwareCVEs": 999, + "numHostsNotResponding": 9, + "aiFeaturesDisabled": true, + "maintenanceWindowsEnabled": true, + "maintenanceWindowsConfigured": true, + "numHostsFleetDesktopEnabled": 999, "hostsEnrolledByOperatingSystem": { "darwin": [ { @@ -101,8 +108,7 @@ Below is the JSON payload that is sent to Fleet Device Management Inc: ] }, ... - ], - "numHostsNotResponding": 9 + ] } ``` @@ -134,6 +140,9 @@ To disable usage statistics: 3. Uncheck the "Enable usage statistics" checkbox and then select "Update settings." - + + + + + - diff --git a/docs/Using Fleet/fleetctl-CLI.md b/articles/fleetctl.md similarity index 92% rename from docs/Using Fleet/fleetctl-CLI.md rename to articles/fleetctl.md index 32b4c6724c..caa234f845 100644 --- a/docs/Using Fleet/fleetctl-CLI.md +++ b/articles/fleetctl.md @@ -1,6 +1,6 @@ -# fleetctl CLI +# fleetctl -fleetctl (pronounced "Fleet control") is a CLI tool for managing Fleet from the command line. fleetctl enables a GitOps workflow with Fleet. +fleetctl (pronounced "Fleet control") is a command line interface (CLI) tool for managing Fleet from the command line. fleetctl enables a GitOps workflow with Fleet. fleetctl also provides a quick way to work with all the data exposed by Fleet without having to use the Fleet UI or work directly with the Fleet API. @@ -32,6 +32,8 @@ npm install -g fleetctl@latest Much of the functionality available in the Fleet UI is also available in `fleetctl`. You can run queries, add and remove users, generate Fleet's agent (fleetd) to add new hosts, get information about existing hosts, and more! +> Note: Unless a logging infrastructure is configured on your Fleet server, osquery-related logs will be stored locally on each device. Read more [here](https://fleetdm.com/guides/log-destinations) + To see the available commands you can run: ```sh @@ -197,6 +199,9 @@ This will generate a `tar.gz` file with: - A file containing a set of all the errors that happened in the server during the interval of time defined by the [logging_error_retention_period](https://fleetdm.com/docs/deploying/configuration#logging-error-retention-period) configuration. - Files containing database-specific information. - + + + + + - diff --git a/docs/Using Fleet/update-agents.md b/articles/fleetd-updates.md similarity index 95% rename from docs/Using Fleet/update-agents.md rename to articles/fleetd-updates.md index 93b61c0052..d5693eba93 100644 --- a/docs/Using Fleet/update-agents.md +++ b/articles/fleetd-updates.md @@ -1,7 +1,6 @@ -# Self-managed agent updates +# Fleetd updates -The fleetd agent will periodically check the public Fleet update repository and update Orbit, Fleet Desktop, and/or osquery -if it detects a later version. +The fleetd agent will periodically check the public Fleet update repository and update Orbit, Fleet Desktop, and/or osquery if it detects a later version. To override this behavior, users can set a channel for each component or disable updates altogether. Visit [Adding Hosts](https://fleetdm.com/docs/using-fleet/adding-hosts#fleet-desktop) to learn more. Alternatively, users with a Fleet Premium subscription can self-manage an update server. @@ -160,6 +159,9 @@ fleetctl updates rotate targets After the key(s) have been rotated, publish the repository in the same fashion as any other update. - + + + + + - diff --git a/articles/install-vpp-apps-on-macos-using-fleet.md b/articles/install-vpp-apps-on-macos-using-fleet.md new file mode 100644 index 0000000000..d5f0da0137 --- /dev/null +++ b/articles/install-vpp-apps-on-macos-using-fleet.md @@ -0,0 +1,114 @@ +# Install App Store apps (VPP) on macOS, iOS, and iPadOS using Fleet + +![Install VPP apps on macOS using Fleet](../website/assets/images/articles/install-vpp-apps-on-macos-using-fleet-1600x900@2x.png) + + +Fleet Premium supports the ability to add Apple App Store applications to your software library using the Volume Purchasing Program (VPP) and then install those apps on macOS, iOS, or iPadOS hosts. This guide will walk you through using this feature to add apps from your Apple Business Manager account to Fleet and install those apps on your hosts. + +The Volume Purchasing Program is an Apple initiative that allows organizations to purchase and distribute apps and books in bulk. This program is particularly beneficial for organizations that need to deploy multiple apps to many devices. Key benefits of VPP include: +* **Bulk purchasing**: Purchase multiple licenses for an app in one transaction, often with volume discounts. +* **Centralized management**: Manage and distribute purchased apps from a central location. +* **Licensing flexibility**: Reassign app licenses as needed, ensuring efficient use of resources. +* **Streamlined deployment**: Use Fleet to automate the installation and configuration of purchased apps on enrolled devices. +* **Self-Service (macOS only)**: Allow users to assign licenses to their own devices as needed. + +By integrating VPP with Fleet, organizations can seamlessly add apps to their software library and deploy them across macOS, iOS, and iPadOS hosts, ensuring that all devices have the necessary applications installed efficiently and effectively. + +## Prerequisites +* **MDM features**: to use the VPP integration, you must first enable MDM features in Fleet. See the [MDM setup guide](https://fleetdm.com/docs/using-fleet/mdm-setup) for instructions on enabling MDM features. +* **Teams**: Apps can only be added to a specific Team. You can manage teams by selecting your avatar in the top navigation and then **Settings > Teams**. (Note: Apps can also be added to the 'No Team' team, which contains hosts not assigned to any other team.) You can control which team uses which VPP token by assigning teams to the VPP token. Each token may have multiple teams assigned to it, but each team may be assigned to only 1 token. + +> As of Fleet 4.55.0, there is a [known issue](https://github.com/fleetdm/fleet/issues/20686) that uninstalled or deleted VPP apps will continue to show a status of `installed`. + +## Accessing the VPP configuration + +1. **Navigate to the MDM integration settings page**: Click your avatar on the far right of the main navigation menu, and then **Settings > Integrations > "Mobile device management (MDM)"** + +2. **Add your VPP token**: Scroll to the "Volume Purchasing Program (VPP)" section. Click "Add VPP", and then click "Add VPP" again on the following page. Follow the directions on the modal to get your VPP token from Apple Business Manager, and then click the "Upload" button at the bottom to upload it to Fleet. + +3. **Edit the team assignment for the new token**: Find the token in the table of VPP tokens. Click the "Actions" dropdown, and then click "Edit teams". Use the picker to select which team(s) this VPP token should be assigned to. + +## Purchasing apps + +To add apps to Fleet, you must first purchase them through Apple Business Manager, even if they are free. This ensures that all apps are appropriately licensed and available for distribution via the Volume Purchasing Program (VPP). For detailed instructions on selecting and buying content, please refer to Apple’s documentation on [purchasing apps through Apple Business Manager](https://support.apple.com/guide/apple-business-manager/select-and-buy-content-axmc21817890/web). + +## Add an app to Fleet + +1. **Navigate to the Software page**: Click on the "Software" tab in the main navigation menu. + +2. **Select your team**: Click on the "All teams" dropdown in the top left of the page and select your desired team. + +3. **Open the "Add software" modal**: Click on the "Add software" button in the top right of the page. + +4. **View your available apps**: Click on the "App Store (VPP)" tab in the "Add software" modal. The modal will list the apps that you have purchased through VPP but still need to add to Fleet. + +5. **Add an app**: Select an app from the list. You may optionally check the "Self-Service" box at the bottom left of the modal if you wish for the software to be available for user-initiated installs. Finally, click the "Add software" button in the bottom right of the modal. The app should appear in the software list for the selected team. + +## Remove an app from Fleet + +1. **Navigate to the Software page**: Click "Software" in the main navigation menu. + +2. **Find the app you want to remove**: Search for the app using the search bar in the top right corner of the table. + +3. **Access the app's details page**: Click on the app's name in the table. + +4. **Remove the app**: Click on the "Actions" dropdown on the right side of the page. Click "Delete," then click "Delete" on the confirmation modal. Deleting an app will not uninstall the app from the hosts on which it was previously installed. + +## Installing apps on macOS, iOS, and iPadOS hosts + +1. **Add the host to the relevant team.** + +2. **Go to the host's detail page**: Click the "Hosts" tab in the main navigation menu. Filter the hosts by the team, and click the host's name to see its details page. + +3. **Find the app**: Click the "Software" tab on the host details page. Search for the software you added in the software table's search bar. Instead of searching, you can also filter software by clicking the **All software** dropdown and selecting **Available for install.** + +4. **Install the app**: Click the "Actions" dropdown on the far right of the app's entry in the + table. Click "Install" to trigger an install. This action will send an MDM command to the host + instructing it to install the app. If the host is offline, the upcoming install will show up in + the **Details** -> **Activity** -> **Upcoming** tab of this page. After the app is installed and + the host details are refetched, the app will show up as **Installed** in the **Software** tab. + +## Installing apps on macOS using self-service + +1. **Open Fleet from the host**: On the host that will be installing an application through self-service, click on the Fleet Desktop tray icon, then click **My Device**. This will open the browser to the device's page on Fleet. + +2. **Navigate to the self-service tab**: Click on the **Self-Service** tab under the device's details. + +3. **Locate the app and click install**: Scroll through the list of software to find the app you would like to install, then click the **Install** button underneath it. + +## Renewing an expired or expiring VPP token + +When one of your uploaded VPP tokens has expired or is within 30 days of expiring, you will see a warning +banner at the top of page reminding you to renew your token. You can do this with the following steps: + +1. **Navigate to the MDM integration settings page**: Click your avatar on the far right of the main navigation menu, and then **Settings > Integrations > "Mobile device management (MDM)"** Scroll to the "Volume Purchasing Program (VPP)" section, and click "Edit". + +2. **Renew the token**: Find the VPP token that you want to renew in the table. Token status is indicated in the "Renew date" column: tokens less than 30 days from expiring will have a yellow indicator, and expired tokens will have a red indicator. Click the "Actions" dropdown for the token and then click "Renew". Follow the instructions in the modal to download a new token from Apple Business Manager and then upload the new token to Fleet. + +## Deleting a VPP token + +To remove VPP tokens from Fleet: + +1. **Navigate to the MDM integration settings page**: Click your avatar on the far right of the main navigation menu, and then **Settings > Integrations > "Mobile device management (MDM)"** Scroll to the "Volume Purchasing Program (VPP)" section, and click "Edit". + +2. **Delete the token**: Find the VPP token that you want to delete in the table. Click the "Actions" dropdown for that token, and then click "Delete". Click "Delete" in the confirmation modal to finish deleting the token. + +## Managing apps with GitOps + +To manage App Store apps using Fleet's best practice GitOps, check out the `software` key in the GitOps reference documentation [here](https://fleetdm.com/docs/using-fleet/gitops#software). + +## REST API + +Fleet also provides a REST API for managing apps programmatically. You can add, install, and delete apps via this API and manage your organization’s VPP tokens. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api). + +## Conclusion + +This feature extends Fleet's capabilities for managing macOS, iOS, and iPadOS hosts. Whether you manage your hosts' software via uploaded installers or via the App Store VPP integration, Fleet provides you with the tools you need to manage your hosts effectively. + + + + + + + + diff --git a/docs/Using Fleet/Log-destinations.md b/articles/log-destinations.md similarity index 92% rename from docs/Using Fleet/Log-destinations.md rename to articles/log-destinations.md index fb153f2589..ddec0c9b0c 100644 --- a/docs/Using Fleet/Log-destinations.md +++ b/articles/log-destinations.md @@ -1,19 +1,5 @@ # Log destinations -- [Log destinations](#log-destinations) - - [Amazon Kinesis Data Firehose](#amazon-kinesis-data-firehose) - - [Snowflake](#snowflake) - - [Splunk](#splunk) - - [Amazon Kinesis Data Streams](#amazon-kinesis-data-streams) - - [AWS Lambda](#aws-lambda) - - [Google Cloud Pub/Sub](#google-cloud-pubsub) - - [Apache Kafka](#apache-kafka) - - [Stdout](#stdout) - - [Filesystem](#filesystem) - - [Sending logs outside of Fleet](#sending-logs-outside-of-fleet) - -This document provides a list of the supported log destinations in Fleet. - Log destinations can be used in Fleet to log: - Osquery [status logs](https://osquery.readthedocs.io/en/stable/deployment/logging/#status-logs). @@ -23,11 +9,27 @@ Log destinations can be used in Fleet to log: To configure each log destination, you must set the correct logging configuration options in Fleet. + Check out the reference documentation for: - [Osquery status logging configuration options](https://fleetdm.com/docs/deploying/configuration#osquery-status-log-plugin). - [Osquery result logging configuration options](https://fleetdm.com/docs/deploying/configuration#osquery-result-log-plugin). - [Activity audit logging configuration options](https://fleetdm.com/docs/deploying/configuration#activity_audit_log_plugin). +This guide provides a list of the supported log destinations in Fleet. + +### In this guide: + +- [Amazon Kinesis Data Firehose](#amazon-kinesis-data-firehose) +- [Snowflake](#snowflake) +- [Splunk](#splunk) +- [Amazon Kinesis Data Streams](#amazon-kinesis-data-streams) +- [AWS Lambda](#aws-lambda) +- [Google Cloud Pub/Sub](#google-cloud-pubsub) +- [Apache Kafka](#apache-kafka) +- [Stdout](#stdout) +- [Filesystem](#filesystem) +- [Sending logs outside of Fleet](#sending-logs-outside-of-fleet) + ## Amazon Kinesis Data Firehose Logs are written to [Amazon Kinesis Data Firehose (Firehose)](https://aws.amazon.com/kinesis/data-firehose/). @@ -145,6 +147,9 @@ See the [osquery logging documentation](https://osquery.readthedocs.io/en/stable If `--logger_plugin=tls` is used with osquery clients, the following configuration can be applied on the Fleet server for handling the incoming logs. - + + + + + - diff --git a/articles/macos-mdm-setup.md b/articles/macos-mdm-setup.md new file mode 100644 index 0000000000..bc91ee6a72 --- /dev/null +++ b/articles/macos-mdm-setup.md @@ -0,0 +1,65 @@ +# macOS MDM setup + +To turn on macOS, iOS, and iPadOS MDM features, follow the instructions on this page to connect Fleet to Apple Push Notification service (APNs). + +To use automatic enrollment (aka zero-touch) features on macOS, iOS, and iPadOS, follow instructions to connect Fleet with Apple Business Manager (ABM). + +To turn on Windows MDM features, head to this [Windows MDM setup article](https://fleetdm.com/guides/windows-mdm-setup). + +## Apple Push Notification service (APNs) + +Apple uses APNs to authenticate and manage interactions between Fleet and hosts. + +To connect Fleet to APNs or renew APNs, head to the **Settings > Integrations > Mobile device management (MDM)** page. + +> Apple requires that APNs certificates are renewed annually. +> - If your certificate expires, you will have to turn MDM off and back on for all macOS hosts. +> - Be sure to use the same Apple ID from year-to-year. If you don't, you will have to turn MDM off and back on for all macOS hosts. + +## Apple Business Manager (ABM) + +> Available in Fleet Premium + +To connect Fleet to ABM, you have to add an ABM token to Fleet. To add an ABM token: + +1. Navigate to the **Settings > Integrations > Mobile device management (MDM)** page. +2. Under "Automatic enrollment", click "Add ABM", and then click "Add ABM" again on the next page. Follow the instructions in the modal and upload an ABM token to Fleet. + +When one of your uploaded ABM tokens has expired or is within 30 days of expiring, you will see a warning +banner at the top of page reminding you to renew your token. + +To renew an ABM token: + +1. Navigate to the **Settings > Integrations > Mobile device management (MDM)** page. +2. Under "Automatic enrollment", click "Edit", and then find the token that you want to renew. Token status is indicated in the "Renew date" column: tokens less than 30 days from expiring will have a yellow indicator, and expired tokens will have a red indicator. Click the "Actions" dropdown for the token and then click "Renew". Follow the instructions in the modal to download a new token from Apple Business Manager and then upload the new token to Fleet. + +After connecting Fleet to ABM, set Fleet to be the MDM for all Macs: + +1. Log in to [Apple Business Manager](https://business.apple.com) +2. Click your profile icon in the bottom left +3. Click **Preferences** +4. Click **MDM Server Assignment** and click **Edit** next to **Default Server Assignment**. +5. Switch **Mac**, **iPhone**, and **iPad** to Fleet. + +macOS, iOS, and iPadOS hosts listed in ABM and associated to a Fleet instance with MDM enabled will sync to Fleet and appear in the Hosts view with the **MDM status** label set to "Pending". + +Hosts that automatically enroll will be assigned to a default team. You can configure the default team for macOS, iOS, and iPadOS hosts by: + +1. Navigating to the **Settings > Integrations > Mobile device management (MDM)** page and clicking "Edit" under "Automatic enrollment". +2. Click on the "Actions" dropdown for the ABM token you want to update, and then click "Edit teams". +3. Use the dropdowns in the modal to select the default team for each type of host, and click "Save" to save your selections. + +If no default team is set for a host platform (macOS, iOS, or iPadOS), then newly enrolled hosts of that platform will be placed in "No 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**. + +### Simple Certificate Enrollment Protocol (SCEP) + +Fleet uses SCEP certificates (1 year expiry) to authenticate the requests hosts make to Fleet. Fleet renews each host's SCEP certificates automatically every 180 days. + + + + + + + diff --git a/docs/Using Fleet/MDM-macOS-setup-experience.md b/articles/macos-setup-experience.md similarity index 91% rename from docs/Using Fleet/MDM-macOS-setup-experience.md rename to articles/macos-setup-experience.md index 31dac0a131..6d7f9cc2ef 100644 --- a/docs/Using Fleet/MDM-macOS-setup-experience.md +++ b/articles/macos-setup-experience.md @@ -2,7 +2,7 @@ _Available in Fleet Premium_ -In Fleet, you can customize the out-of-the-box macOS setup experience for your end users: +In Fleet, you can customize the out-of-the-box macOS Setup Assistant with Remote Management and Automated Device Enrollment (ADE) for end users: * Require end users to authenticate with your identity provider (IdP) and agree to an end user license agreement (EULA) before they can use their new Mac. @@ -12,7 +12,7 @@ In Fleet, you can customize the out-of-the-box macOS setup experience for your e In addition to the customization above, Fleet automatically installs the fleetd agent during out-of-the-box macOS setup. This agent is responsible for reporting host vitals to Fleet and presenting Fleet Desktop to the end user. -macOS setup features require connecting Fleet to Apple Business Manager (ABM). Learn how [here](./mdm-setup.md#apple-business-manager-abm). +macOS setup features require connecting Fleet to Apple Business Manager (ABM). Learn how [here](https://fleetdm.com/guides/macos-mdm-setup#apple-business-manager-abm). ## End user authentication and EULA @@ -20,7 +20,7 @@ Using Fleet, you can require end users to authenticate with your identity provid ### End user authentication -To require end user authentication, first [configure single sign-on (SSO)](../Deploy/single-sign-on-sso.md). Next, enable end user authentication by heading to to **Controls > Setup experience End user authentication** or use [Fleet's GitOps workflow](https://github.com/fleetdm/fleet-gitops). +To require end user authentication, first [configure single sign-on (SSO)](https://fleetdm.com/docs/deploy/single-sign-on-sso). Next, enable end user authentication by heading to to **Controls > Setup experience End user authentication** or use [Fleet's GitOps workflow](https://github.com/fleetdm/fleet-gitops). If you've already configured SSO in Fleet, create a new SAML app in your IdP. In your new app, use `https:///api/v1/fleet/mdm/sso/callback` for the SSO URL. @@ -155,13 +155,15 @@ Testing requires a test Mac that is present in your Apple Business Manager (ABM) 2. In Fleet, navigate to the Hosts page and find your Mac. Make sure that the host's **MDM status** is set to "Pending." - > New Macs purchased through Apple Business Manager appear in Fleet with MDM status set to "Pending." Learn more about these hosts [here](./mdm-setup.md#pending-hosts). + > New Macs purchased through Apple Business Manager appear in Fleet with MDM status set to "Pending." Learn more about these hosts [here](https://fleetdm.com/guides/macos-mdm-setup#apple-business-manager-abm). 3. Transfer this host to the "Workstations (canary)" team by selecting the checkbox to the left of the host and selecting **Transfer** at the top of the table. In the modal, choose the Workstations (canary) team and select **Transfer**. 4. Boot up your test Mac and complete the custom out-of-the-box setup experience. - - + + + + + - diff --git a/docs/Using Fleet/MDM-commands.md b/articles/mdm-commands.md similarity index 70% rename from docs/Using Fleet/MDM-commands.md rename to articles/mdm-commands.md index c541c7799d..b1e5918c5e 100644 --- a/docs/Using Fleet/MDM-commands.md +++ b/articles/mdm-commands.md @@ -1,4 +1,4 @@ -# Commands +# MDM commands In Fleet you can run MDM commands to take action on your macOS, iOS, iPadOS, and Windows hosts, like restarting the host, remotely. @@ -83,19 +83,11 @@ You can view a list of the 1,000 latest commands: 1. Run `fleetctl get mdm-commands` 2. View the list of latest commands, most recent first, along with the timestamp, targeted hostname, command type, execution status and command ID. -The command ID can be used to view command results as documented in [step 4 of the previous section](#step-4-view-the-commands-results). +The command ID can be used to view command results as documented in [step 4 of the previous section](#step-4-view-the-commands-results). -The possible statuses for macOS, iOS, and iPadOS hosts are the following: - -* Pending: the command has yet to run on the host. The host will run the command the next time it comes online. -* NotNow: the host responded with "NotNow" status via the MDM protocol: the host received the command, but couldn’t execute it. The host will try to run the command the next time it comes online. -* Acknowledged: the host responded with "Acknowledged" status via the MDM protocol: the host processed the command successfully. -* Error: the host responded with "Error" status via the MDM protocol: an error occurred. Run the `fleetctl get mdm-command-results --id= - + + + + + - diff --git a/articles/mdm-migration.md b/articles/mdm-migration.md new file mode 100644 index 0000000000..b28f6febd0 --- /dev/null +++ b/articles/mdm-migration.md @@ -0,0 +1,123 @@ +# MDM migration + +This guide provides instructions for migrating devices from your current MDM solution to Fleet. + +> For seamless MDM migration, [view this guide](https://fleetdm.com/guides/seamless-mdm-migration). + +## Requirements + +- A [deployed Fleet instance](https://fleetdm.com/docs/deploy/deploy-fleet) +- Fleet is connected to Apple Push Notification service (APNs) and Apple Business Manager (ABM). [See macOS MDM setup](https://fleetdm.com/guides/macos-mdm-setup) + +## Migrate hosts + +To migrate hosts, we will do the following steps: + +1. Enroll hosts to Fleet +2. Assign hosts in Apple Business Manager (ABM) to Fleet +3. Choose migration workflow and migrate hosts + +### Step 1: enroll hosts to Fleet + +1. First, enroll your hosts to Fleet by installing Fleet's agent (fleetd). Learn how [here](https://fleetdm.com/guides/enroll-hosts). +2. Ensure your end users have access to an admin account on their Mac. End users won't be able to migrate on their own if they have a standard account. + +### Step 2: assign hosts in Apple Business Manager (ABM) to Fleet + +1. In ABM, unassign your hosts from your current MDM solution by selecting **Devices** and then selecting **All Devices**. Then, select **Edit** next to **Edit MDM Server**, select **Unassign from the current MDM**, and select **Continue**. + +2. Assign these hosts to Fleet: select **Devices** and then select **All Devices**. Then, select **Edit** next to **Edit MDM Server**, select **Assign to the following MDM:**, select your Fleet server in the dropdown, and select **Continue**. + +### Step 3: choose migration workflow and migrate hosts + +There are two migration workflows in Fleet: default and end user. + +The default migration workflow requires that the IT admin unenrolls hosts from the old MDM solution before the end user can complete migration. This will result in a gap in MDM coverage until the end user completes migration. + +The end user migration workflow allows the user to kick off migration by unenrolling from the old MDM solution on their own. Once the user is unenrolled, they're prompted to turn on MDM features in Fleet, reducing the gap in MDM coverage. + +#### Default workflow + +End user experience: + +- After a host is unenrolled from your current MDM solution, the end user will be prompted with Apple's **Remote Management** full-screen popup if the host is assigned to Fleet in ABM. +macOS Remote Management popup +- If the host is not assigned to Fleet in ABM (manual enrollment), the end user will be given the option to download the MDM enrollment profile on their **My device page**. +Fleet icon in menu bar +My device page - turn on MDM + +Configuration: + +- To kick off the default workflow, unenroll the hosts to be migrated in your current MDM solution. MacOS does not allow a host to be connected to multiple MDM solutions at once. + +#### End user workflow + +> Available in Fleet Premium + +End user experience: + +- To watch an animation of the end user experience during the migration workflow, head to **Settings > Integrations > Mobile device management (MDM)** in the Fleet UI, and scroll down to the **End user migration workflow** section. + +Configuration: + +- In Fleet, you can configure the end user workflow using the Fleet UI, Fleet API, or Fleet's GitOps workflow. + +- After configuring the end user workflow, instruct your end users to select the Fleet icon in their menu bar, select **Migrate to Fleet** and follow the on-screen instructions to migrate to Fleet. + +- Fleet UI: +1. Select the avatar on the right side of the top navigation and select **Settings > Integrations > Mobile device management (MDM)**. +2. Scroll down to the **End user migration workflow** section and select the toggle to enable the workflow. +3. Under **Mode**, choose a mode, enter the webhook URL for your automation tool (e.g., Tines) under **Webhook URL**, and select **Save**. +4. During the end user migration workflow, an end user's device will have its selected system theme (light or dark) applied. If your logo is not easy to see on both light and dark backgrounds, you can optionally set a logo for each theme: +Head to **Settings** > **Organization settings** > **Organization info**, add URLs to your logos in the **Organization avatar URL (for dark backgrounds)** and **Organization avatar URL (for light backgrounds)** fields, and select **Save**. +- Fleet API: API documentation is [here](https://fleetdm.com/docs/rest-api/rest-api#mdm-macos-migration) +- GitOps: + - To manage macOS MDM migration configuration using Fleet's best practice GitOps, check out the `macos_migration` key in the [GitOps reference documentation](https://fleetdm.com/docs/configuration/yaml-files#macos-migration). + - To manage your organization's logo for dark and light backgrounds using Fleet's best practice GitOps, check out the `org_info` key in the [GitOps reference documentation](https://fleetdm.com/docs/configuration/yaml-files#org-info). + +## Check migration progress + +To see a report of which hosts have successfully migrated to Fleet, have MDM features off, or are still enrolled to your old MDM solution head to the **Dashboard** page by clicking the icon on the left side of the top navigation bar. + +Then, scroll down to the **Mobile device management (MDM)** section of the Dashboard. You'll see a breakdown of which hosts have successfully migrated to Fleet, which have MDM features disabled, and which are still enrolled in the previous MDM solution. + +## FileVault recovery keys + +_Available in Fleet Premium_ + +When migrating from a previous MDM, end users must restart or log out of their device to escrow FileVault keys to Fleet. The **My device** page in Fleet Desktop will present users with instructions on how to reset their key. + +To start, enforce FileVault disk encryption and escrow recovery keys in Fleet. Learn how [here](https://fleetdm.com/guides/enforce-disk-encryption). + +After turning on disk encryption in Fleet, share [these guided instructions](#how-to-turn-on-disk-encryption) with your end users. + +### How to turn on disk encryption + +1. Select the Fleet icon in your menu bar and select **My device**. + +![Fleet icon in menu bar](https://raw.githubusercontent.com/fleetdm/fleet/main/website/assets/images/articles/fleet-desktop-says-hello-world-cover-1600x900@2x.jpg) + +2. On your **My device** page, follow the disk encryption instructions in the yellow banner. + - If you don’t see the yellow banner, select the purple **Refetch** button at the top of the page. + - If you still don't see the yellow banner after a couple minutes or if the **My device** page presents you with an error, please contact your IT administrator. + +My device page - turn on disk encryption + +## Activation Lock + +In Fleet, the [Activation Lock](https://support.apple.com/en-us/HT208987) feature is disabled by default for automatically enrolled (ADE) hosts. + +In 2024, Apple added the ability to manage activation lock in Apple Business Manager (ABM). For devices that are owned by the business and available in ABM, you can [turn off activation lock remotely](https://support.apple.com/en-ca/guide/apple-business-manager/axm812df1dd8/web). + +If a device is not available in ABM and has Activation Lock enabled, we recommend asking the end user to follow these instructions to disable Activation Lock before migrating the device to Fleet: https://support.apple.com/en-us/HT208987. + +If the Activation Lock is enabled, you will need the Activation Lock bypass code to wipe and reuse the Mac successfully. + +However, Activation Lock bypass codes can only be retrieved from the Mac up to 30 days after the device is enrolled. This means that when migrating from your old MDM solution, it’s likely that you’ll be unable to retrieve the Activation Lock bypass code. + + + + + + + diff --git a/articles/osquery-evented-tables-overview.md b/articles/osquery-evented-tables-overview.md index 883f0bc8ab..f1316a85f1 100644 --- a/articles/osquery-evented-tables-overview.md +++ b/articles/osquery-evented-tables-overview.md @@ -121,7 +121,7 @@ On macOS, there are two utilities that enable osquery process auditing: [OpenBSM To use the `es_process_events` tables, use the flag `--disable_endpointsecurity=false`. See the [EndpointSecurity instructions](https://osquery.readthedocs.io/en/latest/deployment/process-auditing/#auditing-processes-with-endpointsecurity) for more information. To use `process_events` and `socket_events` with OpenBSM, see the [OpenBSM instructions](https://osquery.readthedocs.io/en/latest/deployment/process-auditing/#auditing-processes-with-openbsm). #### Windows -Currently, osquery does not support process auditing for Windows. To learn more about process auditing on Windows, visit [Microsoft's security auditing overview](https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/security-auditing-overview). Fleet is tracking work to build process auditing for Windows in osquery. [Stay up to date on GitHub](https://github.com/fleetdm/fleet/issues/7732). +Fleet supports auditing process events on Windows via the `process_etw_events` table. To learn more about process auditing on Windows, visit [Microsoft's security auditing overview](https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/security-auditing-overview). Fleet is tracking work to add file auditing for Windows in osquery. [Stay up to date on GitHub](https://github.com/fleetdm/fleet/issues/20946). ### YARA scanning [YARA](https://virustotal.github.io/yara/) is a malware research and detection tool available on Linux and macOS that allows users to create descriptions of malware families based on patterns of text or binary code. Each potential piece of malware is matched against a YARA rule and triggers if the specified conditions are met. diff --git a/docs/Using Fleet/Osquery-process.md b/articles/osquery-watchdog.md similarity index 90% rename from docs/Using Fleet/Osquery-process.md rename to articles/osquery-watchdog.md index 68efcd81da..5fb0bd980b 100644 --- a/docs/Using Fleet/Osquery-process.md +++ b/articles/osquery-watchdog.md @@ -1,4 +1,4 @@ -# Osquery children processes +# Osquery watchdog Osquery will run a watcher process to keep track of any child process and any managed extensions. What follows is a description of what happens during the watcher REPL and under what circumstances the child process and/or managed extensions are terminated. @@ -25,6 +25,9 @@ If the managed extension is `Non-existent` (either because it was `Non-existent` Lastly, we check the state of the watcher process itself. If it is deemed unhealthy because of resource contention, then the osquery process is shut down. - + + + + + - \ No newline at end of file diff --git a/docs/Using Fleet/Puppet-module.md b/articles/puppet-module.md similarity index 96% rename from docs/Using Fleet/Puppet-module.md rename to articles/puppet-module.md index 30db545834..bf6a442bc1 100644 --- a/docs/Using Fleet/Puppet-module.md +++ b/articles/puppet-module.md @@ -151,7 +151,9 @@ if $err != '' { The above example includes the XML payload for the `EnableRemoteDesktop` MDM command. Learn more about creating the payload for other custom commands [here](./MDM-commands.md). - - + + + + + - diff --git a/docs/Using Fleet/Fleet-UI.md b/articles/queries.md similarity index 73% rename from docs/Using Fleet/Fleet-UI.md rename to articles/queries.md index 3ccd10d5e4..0379bca004 100644 --- a/docs/Using Fleet/Fleet-UI.md +++ b/articles/queries.md @@ -1,19 +1,25 @@ -# Fleet UI -- [Creating a query](#create-a-query) -- [Running a query](#run-a-query) -- [Scheduling a query](#schedule-a-query) -- [Update agent options](#update-agent-options) +# Queries + +Queries in Fleet allow you to ask questions to help you manage, monitor, and identify threats on your devices. This guide will walk you through how to create, schedule, and run a query. + +> Note: Unless a logging infrastructure is configured on your Fleet server, osquery-related logs will be stored locally on each device. Read more [here](https://fleetdm.com/guides/log-destinations) + +> New users may find it helpful to start with Fleet's policies. You can find policies and queries from the community in Fleet's [query library](https://fleetdm.com/queries). To learn more about policies, see [What are Fleet policies?](https://fleetdm.com/securing/what-are-fleet-policies) and [Understanding the intricacies of Fleet policies](https://fleetdm.com/guides/understanding-the-intricacies-of-fleet-policies). + +### In this guide: + +- [Create a query](#create-a-query) +- [Run a query](#run-a-query) +- [Schedule a query](#schedule-a-query)
+ + ## Create a query -Queries in Fleet allow you to ask a multitude of questions to help you manage, monitor, and identify threats on your devices. - -If you're unsure of what to ask, head to Fleet's [query library](https://fleetdm.com/queries). There you'll find common queries that have been tested by members of our community. - How to create a query: 1. In the top navigation, select **Queries**. @@ -63,16 +69,10 @@ By default, queries that run on a schedule will only target platforms compatible > Note: When viewing a specific [team](https://fleetdm.com/docs/using-fleet/segment-hosts) in Fleet Premium, only queries that belong to the selected team will be listed. When configuring query automations for all hosts, only global queries will be listed. -## Update agent options - - - -> This content was relocated on 31st August 2023. - -See "[Agent configuration](https://fleetdm.com/docs/configuration/agent-configuration)" to learn how to simultaneously update agent options from the Fleet UI or fleetctl command line tool. - - - + + + + + - diff --git a/docs/Using Fleet/manage-access.md b/articles/role-based-access.md similarity index 97% rename from docs/Using Fleet/manage-access.md rename to articles/role-based-access.md index b472b33539..95fc712c52 100644 --- a/docs/Using Fleet/manage-access.md +++ b/articles/role-based-access.md @@ -1,4 +1,4 @@ -# Manage access +# Role-based access Users have different abilities depending on the access level they have. @@ -83,7 +83,7 @@ GitOps is an API-only and write-only role that can be used on CI/CD pipelines. | View Apple business manager (BM) information | | | | ✅ | | | Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | | ✅ | | | View disk encryption key for macOS and Windows hosts | ✅ | ✅ | ✅ | ✅ | | -| Edit OS updates for macOS and Windows hosts | | | ✅ | ✅ | ✅ | +| Edit OS updates for macOS, Windows, iOS, and iPadOS hosts | | | ✅ | ✅ | ✅ | | Create, edit, resend and delete configuration profiles for macOS and Windows hosts | | | ✅ | ✅ | ✅ | | Execute MDM commands on macOS and Windows hosts\** | | | ✅ | ✅ | | | View results of MDM commands executed on macOS and Windows hosts\** | ✅ | ✅ | ✅ | ✅ | | @@ -154,7 +154,7 @@ Users with access to multiple teams can be assigned different roles for each tea | Edit agent options | | | | ✅ | ✅ | | Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | | ✅ | ✅ | | | View disk encryption key for macOS hosts | ✅ | ✅ | ✅ | ✅ | | -| Edit OS updates for macOS and Windows hosts | | | ✅ | ✅ | ✅ | +| Edit OS updates for macOS, Windows, iOS, and iPadOS hosts | | | ✅ | ✅ | ✅ | | Create, edit, resend and delete configuration profiles for macOS and Windows hosts | | | ✅ | ✅ | ✅ | | Execute MDM commands on macOS and Windows hosts* | | | ✅ | ✅ | | | View results of MDM commands executed on macOS and Windows hosts* | ✅ | ✅ | ✅ | ✅ | | @@ -175,6 +175,9 @@ Users with access to multiple teams can be assigned different roles for each tea \** Team-level users only see global query results for hosts on teams where they have access. - + + + + + - diff --git a/docs/Using Fleet/Scripts.md b/articles/scripts.md similarity index 88% rename from docs/Using Fleet/Scripts.md rename to articles/scripts.md index 7adb2c057d..754fdf4da9 100644 --- a/docs/Using Fleet/Scripts.md +++ b/articles/scripts.md @@ -19,7 +19,7 @@ If you don't use MDM features, to enable scripts, we'll deploy a fleetd agent wi 2. Deploy fleetd to your hosts. If your hosts already have fleetd installed, you can deploy the new fleetd on-top of the old installation. -Learn more about generating a fleetd agent and deploying it [here](./enroll-hosts.md). +Learn more about generating a fleetd agent and deploying it [here](https://fleetdm.com/guides/enroll-hosts). ## Execute a script @@ -45,7 +45,9 @@ fleetctl CLI: fleetctl run-script --script-path=/path/to/script --host=hostname ``` - - + + + + + - diff --git a/articles/seamless-mdm-migration.md b/articles/seamless-mdm-migration.md new file mode 100644 index 0000000000..9abf3c516c --- /dev/null +++ b/articles/seamless-mdm-migration.md @@ -0,0 +1,133 @@ +# Seamless MDM migrations to Fleet + +![Seamless MDM migrations to Fleet](../website/assets/images/articles/seamless-mdm-migration-1600x900@2x.png) + +Migrating macOS devices between Mobile Device Management (MDM) solutions is often fraught with challenges, including potential gaps in device management, user disruption, and compliance issues. Traditional MDM migrations typically require end-user interaction and leave devices unmanaged for a period, leading to problems like Wi-Fi disconnections due to certificate profile removal and incomplete migrations. These challenges can force organizations to stay with outdated MDM solutions that no longer meet their needs. But there’s a better way. + +Seamless MDM migrations are now possible, allowing organizations to transition their macOS devices to Fleet without any downtime or end-user involvement. By leveraging Fleet, you can ensure that your devices remain fully managed and compliant throughout the migration process. This means no more gaps in management, no user disruptions, and a smoother path to a more modern and effective MDM solution. + +This guide will walk you through the entire process of migrating your MDM deployment to Fleet. You’ll start by understanding the specific requirements for a seamless migration, followed by configuring Fleet with the necessary certificates and database records. The guide will then take you through the process of installing Fleet’s agent (`fleetd`) on your devices, updating DNS records to redirect devices to the Fleet server, and finally, decommissioning your old MDM server. + +Throughout the guide, you’ll find practical advice and best practices to ensure a smooth transition with minimal risk. By the end, you’ll be equipped with the knowledge and tools to execute a seamless MDM migration to Fleet, ensuring that your organization’s devices are securely managed without the typical headaches associated with a traditional MDM switch. + +## Requirements + +Note: Deployments that do not meet these seamless migration requirements can still migrate with the [standard MDM migration process](https://fleetdm.com/docs/using-fleet/mdm-migration-guide). + +* Customer controls the DNS used in the MDM server enrollment (eg. devices are enrolled to `*.customerowneddomain.com`, not `*.mdmvendor.com`). +* Customer has access to the Apple Push Notification Service (APNS) certificate/key and SCEP certificate/key, or access to the MDM server database to extract these values. + +These requirements are easily met in self-hosted open-source MDM solutions and may be met with commercial solutions when the customer is self-hosting or otherwise controls the DNS. + +Seamless migration may still be possible with control of DNS along with a copy of the original Certificate Signing Request (CSR) for the APNS certificate. If you are in this situation, please reach out to the Fleet team. + +### Why? + +Apple allows changing most values in profiles delivered by MDM, but the `ServerURL`, `CheckinURL`, and `PushTopic` cannot be changed without re-enrollment (and user actions). Control of DNS and the certificates allows the MDM to be swapped out without changing these. + +## High-level process + +1. Configure Fleet with the APNS & SCEP certificates/keys, path redirects, and SCEP renewal. +2. Import database records letting Fleet know about the devices to be migrated. +3. Configure controls (profiles, updates, etc.) in Fleet. +4. Install `fleetd` on the devices (through the existing MDM). +5. Update DNS records to point devices to the Fleet server. +6. Decommission the old server. + +It is recommended to follow the entire process on a staging/test MDM instance and devices, then repeat for the production instance and devices. + +[![Before migration](https://mermaid.ink/img/pako:eNpVUctuwjAQ_BVrT62URIaEvFRxqNKeSivBrZiDiTeJpdhGxqFQBN9eA23VXvY1o9lZ7RFqIxBKCMOQaSddjyV5xMZYJEq2ljtpNNNXtOnNR91x68jLnOntsPbwpiOK128LUuFO1sg0IUqoupeo3XJWzcitXDGNWjD9i5EwJHMzOBRkfSDV64I8rO2U3HlChHuuNj1GtVH3YTg1vfBTpm95-bSXWyd1Sy7qC7Q7tKu_wufzmTQ9orsY9mn5fIk_TAhAoVVcCn_z8WKXgetQIYPSlwIbPvSOAdMnT-WDM4uDrqF0dsAAho3gDivJ_eUKyob3Wz_dcP1uzL8eyiPsoRzTOBrHySim2SQtaBbAAco4S6NxTmmeZcUoLiZJfArg8ypAo5SOKC3iNM-LNMmTJAAU0hk7u32pNrqRrXdmzdB23xtPX3Gkloc?type=png)](https://mermaid.live/edit#pako:eNpVUctuwjAQ_BVrT62URIaEvFRxqNKeSivBrZiDiTeJpdhGxqFQBN9eA23VXvY1o9lZ7RFqIxBKCMOQaSddjyV5xMZYJEq2ljtpNNNXtOnNR91x68jLnOntsPbwpiOK128LUuFO1sg0IUqoupeo3XJWzcitXDGNWjD9i5EwJHMzOBRkfSDV64I8rO2U3HlChHuuNj1GtVH3YTg1vfBTpm95-bSXWyd1Sy7qC7Q7tKu_wufzmTQ9orsY9mn5fIk_TAhAoVVcCn_z8WKXgetQIYPSlwIbPvSOAdMnT-WDM4uDrqF0dsAAho3gDivJ_eUKyob3Wz_dcP1uzL8eyiPsoRzTOBrHySim2SQtaBbAAco4S6NxTmmeZcUoLiZJfArg8ypAo5SOKC3iNM-LNMmTJAAU0hk7u32pNrqRrXdmzdB23xtPX3Gkloc) + +[![After migration](https://mermaid.ink/img/pako:eNpVUcFuwjAM_ZXIu2xSW7XQdaWakCYxTmOT4DayQ0jcNqJJUEgZDMG3L6Vs2g5JbL9n-9k5AjcCoYAwDKl20jVYkKfSoSVKVpY5aTTVF7BszCevmXXkZU71tl15eFMTxfjbgkxwJzlSTYgSijcStVvOJjPSmx9UoxZUm0Z4ePm8l1sndUU6xgLtDq1n_CaS8_lMeurfaBiSuWkdCrI6kMnrgjyu7JjcekKEe6Y2DUbcqLswHJcNousE-2c57e6fLhCAQquYFH7kYyeXgqtRIYXCm42sakch6AHB7Hrmt9NhJWu2eI2vGF9X1rR-okvWzXQ6pUD1yVdnrTOLg-ZQONtiAO1GMIcTyfyyFBR9Gdgw_W7MPx-KI-yhSPI8GgzTJE2T-GGU5UkABygGeRz5k8SDJL8fpGmcnQL4ulSIo8zH49Ewy_NRluZpGgAK6Yyd9R_LjS5l5aV5xVV9bXn6BriRpdY?type=png)](https://mermaid.live/edit#pako:eNpVUcFuwjAM_ZXIu2xSW7XQdaWakCYxTmOT4DayQ0jcNqJJUEgZDMG3L6Vs2g5JbL9n-9k5AjcCoYAwDKl20jVYkKfSoSVKVpY5aTTVF7BszCevmXXkZU71tl15eFMTxfjbgkxwJzlSTYgSijcStVvOJjPSmx9UoxZUm0Z4ePm8l1sndUU6xgLtDq1n_CaS8_lMeurfaBiSuWkdCrI6kMnrgjyu7JjcekKEe6Y2DUbcqLswHJcNousE-2c57e6fLhCAQquYFH7kYyeXgqtRIYXCm42sakch6AHB7Hrmt9NhJWu2eI2vGF9X1rR-okvWzXQ6pUD1yVdnrTOLg-ZQONtiAO1GMIcTyfyyFBR9Gdgw_W7MPx-KI-yhSPI8GgzTJE2T-GGU5UkABygGeRz5k8SDJL8fpGmcnQL4ulSIo8zH49Ewy_NRluZpGgAK6Yyd9R_LjS5l5aV5xVV9bXn6BriRpdY) + +### 1. Configure Fleet + +The Fleet server must be configured with the APNS & SCEP certificates/keys copied from the existing server. This is done via manual modification of the Fleet database and configurations. The Fleet team will perform this configuration on Fleet Cloud instances and can advise how to do it on self-hosted Fleet instances. + +In most cases, the paths (portion of the URL after the domain name) used in the enrollment profile `ServerURL`, `CheckInURL` and SCEP URL will differ from those used by Fleet. The Fleet Server load balancer must be configured to redirect the MDM client via HTTP 3xx redirects. + +[Apple's documentation](https://developer.apple.com/documentation/devicemanagement/implementing_device_management/sending_mdm_commands_to_a_device?language=objc) states: + +> MDM follows HTTP 3xx redirections without user interaction. However, it doesn’t save the URL given by HTTP 301 (Moved Permanently) redirections. Each transaction begins at the URL the MDM payload specifies. + +Therefore, redirects must remain as long as migrated devices are enrolled. + +For a typical MicroMDM to Fleet migration, the following redirects are used: + +| From (MicroMDM path) | To (Fleet path) | +| -------------------- | --------------- | +| /mdm/checkin | /mdm/apple/mdm | +| /mdm/connect | /mdm/apple/mdm | +| /scep | /mdm/apple/scep | + +SCEP certificate renewals need special handling for migrated devices. This is configured (by, or with guidance from the Fleet team) in the server using the [`FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE` environment variable](https://github.com/fleetdm/fleet/pull/20063). When configured, migrated devices receive an enrollment profile with matching keys when SCEP renewal comes due (migrated devices reject the typical profile Fleet sends because it includes the new server URL). + +### 2. Import database records + +The Fleet server is made aware of the devices that will be migrated by inserting records into the database. The Fleet team will perform this operation in Fleet Cloud and can advise for self-hosted instances. + +For MicroMDM, a [migration script](https://github.com/fleetdm/fleet/pull/18151) has been made that will generate the necessary SQL statements from the MicroMDM database. + +For other MDM solutions, please work with the Fleet team to generate the appropriate records. + +### 3. Configure controls + +Next, configure the controls that will be applied to migrated devices. Use the Teams features in Fleet Premium to apply different configurations to different devices. + +In particular, + +* [Configuration profiles](https://fleetdm.com/docs/using-fleet/mdm-custom-os-settings#custom-os-settings) +* [OS updates](https://fleetdm.com/docs/using-fleet/mdm-os-updates) +* [Disk encryption](https://fleetdm.com/docs/using-fleet/mdm-disk-encryption) + +When the device checks in after migration, Fleet will send the full set of configuration profiles configured for that device's team. Any profiles with identifiers matching existing profiles on the device will be updated in place. + +Fleet will not send commands to remove profiles that have not been configured in Fleet. Either remove these profiles before migration in the existing MDM before migration or use `fleetctl` or the Fleet API to send an MDM command to remove any undesired profiles. + +OS update configurations will apply automatically after the device is migrated. + +As of Fleet 4.55, disk encryption keys will automatically be re-escrowed after migration the next time the user logs into their device. + +### 4. Install `fleetd` + +Install `fleetd` on the devices to migrate. Devices with `fleetd` installed will begin to show up in the Fleet UI (with profiles in a "Pending" state). + +Generate `.pkg` packages following the [standard enrollment documentation](https://fleetdm.com/docs/using-fleet/enroll-hosts). Install the package using the existing MDM or any other management tool. + +Devices are automatically assigned to Teams in Fleet based on the package they are provided, so be sure to distribute packages that assign devices to teams with the relevant configurations. + +### 5. Update DNS + +Devices are now communicating with the Fleet server via the `fleetd` agent. They have not yet migrated MDM servers. + +Ensure the Fleet server load balancer can terminate HTTPS using the existing server hostname. This typically involves issuing a certificate [with AWS ACM](https://docs.aws.amazon.com/acm/latest/userguide/gs-acm-request-public.html). In Fleet Cloud, the Fleet team will ask the customer team to update a DNS record for verification so that AWS can issue the certificate. + +Now the customer updates DNS to point the existing domain to the Fleet server load balancer. This typically involves setting a `CNAME` record with the hostname of the load balancer (eg. `mdm.example.com -> fleet-cloud-alb-1723349272.us-east-2.elb.amazonaws.com`). + +Devices will begin checking in with the Fleet server and receiving new configurations. + +### 6. Decommission the old server + +At this point, the migration is complete. The old server can be decommissioned. + +Keep a database backup of the old server on hand in case it is ever needed for reference or recovery. + +## Gradual migration + +In the process described, when we update DNS all of the devices are migrated immediately. To minimize risk, it is often desired to gradually migrate devices. + +Fleet has created a [migration proxy](https://github.com/fleetdm/fleet/tree/main/tools/mdm/migration/mdmproxy) that can be used to gradually migrate specific devices and/or a percentage of devices. This allows a staged migration with progressively more devices migrated. + +## Conclusion + +Seamless MDM migrations on macOS are not just possible but are a significant step forward in maintaining a secure and compliant environment without disrupting end users. By following this guide, you can transition from your existing MDM solution to Fleet smoothly, keeping your devices managed and secure throughout the process. If you encounter any challenges, the Fleet team is ready to assist you, ensuring your migration is successful. + +For organizations ready to take control of their MDM strategy, this seamless migration process is an opportunity to upgrade to a modern, flexible, and secure management solution. We encourage you to reach out for support or further explore the robust features Fleet offers to enhance your device management capabilities. + + + + + + + + diff --git a/articles/software-self-service.md b/articles/software-self-service.md new file mode 100644 index 0000000000..f82a27f8c8 --- /dev/null +++ b/articles/software-self-service.md @@ -0,0 +1,80 @@ +# Software self-service + +![Software self-service](../website/assets/images/articles/software-self-service-1600x900@2x.png) + +Fleet’s self-service software feature empowers end users by allowing them to independently install approved software packages from a curated list through the Fleet Desktop “My device” page. This not only reduces the administrative burden on IT teams but also enhances user productivity and satisfaction. In this guide, we will walk you through the process of uploading, editing, and managing self-service software packages in Fleet, enabling seamless software distribution and management. + +## Prerequisites + +* Fleet Premium is required for software self-service. + +> Software packages can be added to a specific team or to the "No team" category. The "No team" category is the default assignment for hosts that are not part of any specific team. + +## Step-by-Step Instructions + +### Adding a self-service software package + +1. **Navigate to the Software page**: Click “Software” in the main navigation menu. +2. **Select a team**: Click the dropdown in the upper left corner of the page and click on the team to which you want to add the software package. +3. **Open the “Add software” modal**: Click the “Add software” button in the upper right corner of the page. +4. **Select a software package to upload**: Click “Choose file” in the “Add software” modal and select a software package from your computer. +5. **Advanced options**: If desired, click “Advanced options” to add a pre-install condition or post-install script to your software package. + * **Pre-install condition**: This is an osquery query that results in true. For example, you might require a specific software title to exist before installing additional extensions. + * **Post-install script**: This might be used to apply a license key, perform configuration tasks, or execute cleanup tasks after the software installation. +6. **Make the software package self-service**: Check the “Self-service” checkbox to mark the software package as self-service. +7. **Finish the upload**: Click the “Add software” button to finish the upload process. + +### Editing a self-service software package + +1. **Navigate to the software details page for the software package**: Click “Software” in the main navigation menu. +2. **Select a team**: Click the dropdown in the upper left corner of the page and click on the team to which you added the software package. +3. **Filter by self-service**: To make it easier to find your software package, click on the dropdown to the left of the search bar and select “Self-service”. This will filter the results in the table to only show self-service software packages. If you still don’t see your software package, you can page through the results or search for your software package’s name in the search bar. +4. **Open the details page**: Click on the software package’s name. +5. **Open the actions dropdown**: Click on the “Actions” dropdown on the far right of the page. From here, you can download the software package, delete the software package, or click “Advanced options” to see the options you configured when adding the software package. + +### Downloading a self-service software package + +1. **Navigate to the software details page for the software package**: Click “Software” in the main navigation menu. +2. **Select a team**: Click the dropdown in the upper left corner of the page and click on the team to which you added the software package. +3. **Filter by self-service**: Click on the dropdown to the left of the search bar and select “Self-service” and page through the results or search for your software package’s name in the search bar. +4. **Download the software package**: +* **Option 1**: Click on the down-arrow next to the software package name in the list of self-service software packages to start an immediate download. +* **Option 2**: Click on the software package’s name to open the details page. Click on the “Actions” dropdown on the far right of the page, and then click on “Download” to download the software package to your computer. + +### Deleting a self-service software package + +1. **Navigate to the software details page for the software package**: Click “Software” in the main navigation menu. +2. **Select a team**: Click the dropdown in the upper left corner of the page and click on the team to which you added the software package. +3. **Filter by self-service**: Click on the dropdown to the left of the search bar and select “Self-service” and page through the results or search for your software package’s name in the search bar. +4. **Open the details page**: Click on the software package’s name. +5. **Open the actions dropdown**: Click on the “Actions” dropdown on the far right of the page. +6. **Delete the software package**: Click on “Delete” to remove the software package from Fleet. Confirm the deletion if prompted. + +### Installing self-service software packages + +To install the self-service software package on the host: + +1. **Navigate to the “Self-service” tab**: Click on the Fleet Desktop icon in the OS menu bar. Click “Self-service”. This will point your default web browser to the list of self-service software packages in the “My device” page. +2. **Install the self-service software package**: Click the “Install” button for the software package you want to install. + +### Using the REST API for self-service software packages + +Fleet provides a REST API for managing software packages, including self-service software packages. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api#software). + +### Managing self-service software packages with GitOps + +To manage self-service software packages using Fleet's best practice GitOps, check out the `software` key in the [GitOps reference documentation](https://fleetdm.com/docs/using-fleet/gitops#software). + +> Note: with GitOps enabled, software packages uploaded using the web UI will not persist. + +## Conclusion + +Fleet’s self-service software feature not only simplifies software management for IT administrators but also empowers end users by giving them access to necessary software on demand. This feature ensures that your hosts remain secure while improving overall user experience. For further information and advanced management techniques, refer to Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api#software) and [GitOps](https://fleetdm.com/docs/using-fleet/gitops#software) documentation. + + + + + + + + diff --git a/docs/01-Using-Fleet/standard-query-library/README.md b/articles/standard-query-library.md similarity index 87% rename from docs/01-Using-Fleet/standard-query-library/README.md rename to articles/standard-query-library.md index fc3f3bfdc1..7bbdd52bf2 100644 --- a/docs/01-Using-Fleet/standard-query-library/README.md +++ b/articles/standard-query-library.md @@ -47,4 +47,9 @@ Listed below are great resources that contain additional queries. - Osquery (https://github.com/osquery/osquery/tree/master/packs) - Palantir osquery configuration (https://github.com/palantir/osquery-configuration/tree/master/Fleet) - + + + + + + diff --git a/articles/tales-from-fleet-security-securing-the-startup.md b/articles/tales-from-fleet-security-securing-the-startup.md index 09b0ac4fd6..6a8423dfc9 100644 --- a/articles/tales-from-fleet-security-securing-the-startup.md +++ b/articles/tales-from-fleet-security-securing-the-startup.md @@ -43,7 +43,7 @@ I thought using Apple’s Automated Device Enrollment (or Device Enrollment Prog Technically, I was not wrong, but there are non-technical challenges. -1. The requirements to establish a DEP account vary by country. In the US, for example, it requires a [DUNS](https://en.wikipedia.org/wiki/Data_Universal_Numbering_System) number. Getting a DUNS number is simple for US companies, but what is not easy is to fulfill similar requirements in every country where you would like to use DEP. We could not register for DEP in Canada. We have people in many other countries with a similar situation. +1. The requirements to establish a ADE account vary by country. In the US, for example, it requires a [DUNS](https://en.wikipedia.org/wiki/Data_Universal_Numbering_System) number. Getting a DUNS number is simple for US companies, but what is not easy is to fulfill similar requirements in every country where you would like to use ADE. We could not register for ADE in Canada. We have people in many other countries with a similar situation. 2. The delays for obtaining hardware are very long. When planning endpoint deployment strategies, we must consider this, as supply chain issues will not disappear soon. 3. The benchmarks made by the Center for Internet Security (CIS) are excellent but are incredibly long (700+ pages) and written for experts. We wanted to be transparent about why we configured company devices a certain way and explain it so everyone could understand without Googling for hours. @@ -55,9 +55,9 @@ Google should offer more granularity than on/off for third-party cookies, such a ## Solutions -### DEP in other countries +### ADE in other countries -First, we enrolled in DEP in the US. Once we had our customer numbers and Mobile Device Management (MDM) system linked up, we were ready to buy laptops in the US that would get configured out of the box. Then, we found a workaround for Canada. If you add Apple’s Reseller ID to [Apple Business Manager](https://business.apple.com/), you can order computers over the phone and have them linked to your business account. The Reseller ID part is critical. I learned that the hard way, by receiving a laptop ordered like this to find it not part of DEP. Fortunately, it was easy for me to [add it to DEP manually](https://support.apple.com/en-ca/guide/apple-configurator/welcome/ios). +First, we enrolled in ADE in the US. Once we had our customer numbers and Mobile Device Management (MDM) system linked up, we were ready to buy laptops in the US that would get configured out of the box. Then, we found a workaround for Canada. If you add Apple’s Reseller ID to [Apple Business Manager](https://business.apple.com/), you can order computers over the phone and have them linked to your business account. The Reseller ID part is critical. I learned that the hard way, by receiving a laptop ordered like this to find it not part of ADE. Fortunately, it was easy for me to [add it to ADE manually](https://support.apple.com/en-ca/guide/apple-configurator/welcome/ios). We will keep trying the same approach in every country where we need Macs, though we know it will not be possible everywhere. We will either obtain equipment from a nearby country or rely on manual MDM enrollment by end-users for those countries. @@ -76,7 +76,7 @@ Using the [CIS Benchmark for macOS 12](https://www.cisecurity.org/benchmark/appl ### Effort -Implementing our own security baseline, configuring our MDM and DEP required a couple of days of effort, mostly because I insisted on reviewing all of the CIS Benchmark to be certain I didn’t miss something important. Having everything published in our handbook required additional effort, but if you were to use our baseline, you could get started very quickly. The main thing that will slow you down is getting onboarded to DEP, and receiving your first laptop ordered! +Implementing our own security baseline, configuring our MDM and ADE required a couple of days of effort, mostly because I insisted on reviewing all of the CIS Benchmark to be certain I didn’t miss something important. Having everything published in our handbook required additional effort, but if you were to use our baseline, you could get started very quickly. The main thing that will slow you down is getting onboarded to ADE, and receiving your first laptop ordered! ## What's next? diff --git a/articles/tales-from-fleet-security-soc2.md b/articles/tales-from-fleet-security-soc2.md index c5b6d8aaaa..641583270a 100644 --- a/articles/tales-from-fleet-security-soc2.md +++ b/articles/tales-from-fleet-security-soc2.md @@ -43,7 +43,7 @@ One of the essential things about SOC 2 is having the right security policies. T Writing policies from scratch can seem daunting. Many compliance automation products have templates you can use to get started, but there are excellent free and open resources online. -As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/business-operations/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing. +As you can see, our policies are in our [handbook](https://fleetdm.com/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy), and we created most of them using this [free set of templates](https://github.com/JupiterOne/security-policy-templates) published by JupiterOne under Creative Commons licensing. We kept our policies as basic as possible to make sure everything in them is valuable and achievable. Having policies that state you must do the impossible is a surefire way of getting in trouble! The templates we used contained many processes and procedures as well. We used the policies and will eventually document more of our procedures in our handbook. diff --git a/docs/Using Fleet/segment-hosts.md b/articles/teams.md similarity index 87% rename from docs/Using Fleet/segment-hosts.md rename to articles/teams.md index 548bb1c4cd..ea688bc08b 100644 --- a/docs/Using Fleet/segment-hosts.md +++ b/articles/teams.md @@ -1,8 +1,8 @@ -# Segment hosts +# Teams _Available in Fleet Premium_ -In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to the hosts' risk/compliance needs. +In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to a host's risk/compliance needs. A host can only belong to one team. @@ -30,10 +30,13 @@ You can add hosts to a new team in Fleet by either enrolling the host with a tea ## Advanced -You can automatically enroll hosts to a specific team in Fleet by installing a fleetd with a team enroll secret. Learn more [here](./enroll-hosts.md#enroll-host-to-a-specific-team). +You can automatically enroll hosts to a specific team in Fleet by installing a fleetd with a team enroll secret. Learn more [here](https://fleetdm.com/guides/enroll-hosts#enroll-host-to-a-specific-team). Changing the host's enroll secret after enrollment will not cause the host to be transferred to a different team. - + + + + + - diff --git a/docs/Using Fleet/Vulnerability-Processing.md b/articles/vulnerability-processing.md similarity index 54% rename from docs/Using Fleet/Vulnerability-Processing.md rename to articles/vulnerability-processing.md index 2e675e5735..610bd51347 100644 --- a/docs/Using Fleet/Vulnerability-Processing.md +++ b/articles/vulnerability-processing.md @@ -1,7 +1,5 @@ # Vulnerability processing -## Introduction - Vulnerability processing in Fleet detects vulnerabilities (CVEs) for the software installed on your hosts. To see what software is covered, check out the [Coverage section](#coverage). @@ -16,18 +14,27 @@ To see what software is covered, check out the [Coverage section](#coverage). Fleet detects vulnerabilities for these software types: -| Type | macOS | Windows | Linux | -| ------------------- | ------------------------------------------ | ------------------------------------------------ | ---------------- | -| Apps | ✅ | ✅ | ❌ | -| Browser plugins | Chrome extensions, Firefox extensions | Chrome extensions, Firefox extensions | ❌ | -| Packages | Python, Homebrew | Python, Atom, Chocolatey | Packages defined in the [OVAL definitions](https://github.com/fleetdm/nvd/blob/master/oval_sources.json), except for vulnerabilities involving configuration files. Supported distributions:
  • Ubuntu
  • RHEL based distros (Red Hat, CentOS, Fedora, and Amazon Linux)
| -| IDE extensions | VS Code extensions | VS Code extensions | VS Code extensions | +| Type | macOS | Windows | Linux | +| ------------------- | ------------------------------------------ | ------------------------------------------------ |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Apps | ✅ | ✅ | ❌ | +| Browser plugins | Chrome extensions, Firefox extensions | Chrome extensions, Firefox extensions | ❌ | +| Packages | Python, Homebrew | Python, Atom, Chocolatey |

For Ubuntu, Debian, RHEL (including CentOS), and Fedora: packages defined in the [OVAL definitions](https://github.com/fleetdm/nvd/blob/master/oval_sources.json), except for vulnerabilities involving configuration files.

For Amazon Linux, packages maintained by Amazon by checking [ALAS advisories](https://alas.aws.amazon.com/).

| +| IDE extensions | VS Code extensions | VS Code extensions | VS Code extensions | As of right now, only app names with all ASCII characters are supported. Apps with names featuring non-ASCII characters, such as Cyrillic, will not generate matches. For Ubuntu Linux, kernel vulnerabilities with known variants (ie. `-generic`) are detected using OVAL. Custom kernels (unknown variants) are detected using NVD. -### Advanced configuration +## Sources + +Fleet combines multiple sources to get accurate and up-to-date CVE information: +- [National Vulnerability Database](https://nvd.nist.gov/developers/vulnerabilities) CVE feeds +- [VulnCheck](https://vulncheck.com/) CVE feeds +- [Mac Office release notes](https://learn.microsoft.com/en-us/officeupdates/release-notes-office-for-mac) for Office for Mac +- [Microsoft MSRC Security Bulletins](https://msrc.microsoft.com/update-guide) for Windows OS vulnerabilities +- [OVAL definitions](https://github.com/fleetdm/nvd/blob/master/oval_sources.json) for Linux software + +## Advanced configuration Fleet runs vulnerability downloading and processing via internal scheduled cron job. This internal mechanism is very useful for frictionless deployments and is well suited for most use cases. However, in larger deployments, @@ -63,6 +70,9 @@ command. fleet vuln_processing ``` - + + + + + - diff --git a/articles/windows-mdm-setup.md b/articles/windows-mdm-setup.md index 1fb36f3bf6..87188e11ee 100644 --- a/articles/windows-mdm-setup.md +++ b/articles/windows-mdm-setup.md @@ -10,7 +10,7 @@ To use automatic enrollment (aka zero-touch) features on Windows, follow instruc ### Step 1: Generate your certificate and key -Fleet uses a certificate and key pair to authenticate and manage interactions between Fleet and Windows host. +Fleet uses a certificate and key pair to authenticate and manage interactions between the Fleet server and a Windows host. How to generate a certificate and key: diff --git a/assets/images/iPadOS-install-profile.png b/assets/images/iPadOS-install-profile.png new file mode 100644 index 0000000000..c398038517 Binary files /dev/null and b/assets/images/iPadOS-install-profile.png differ diff --git a/assets/images/iPadOS-profile-downloaded.png b/assets/images/iPadOS-profile-downloaded.png new file mode 100644 index 0000000000..f21b12615f Binary files /dev/null and b/assets/images/iPadOS-profile-downloaded.png differ diff --git a/assets/images/ios-install-profile.png b/assets/images/ios-install-profile.png new file mode 100644 index 0000000000..8266c55201 Binary files /dev/null and b/assets/images/ios-install-profile.png differ diff --git a/assets/images/ios-profile-downloaded.png b/assets/images/ios-profile-downloaded.png new file mode 100644 index 0000000000..7941a65815 Binary files /dev/null and b/assets/images/ios-profile-downloaded.png differ diff --git a/changes/13157-fv-escrow b/changes/13157-fv-escrow deleted file mode 100644 index e6804a05ec..0000000000 --- a/changes/13157-fv-escrow +++ /dev/null @@ -1 +0,0 @@ -* `fleetd` now uses Escrow Buddy to rotate FileVault keys. Internal API endpoints documented in the API for contributors have been modified and/or removed. diff --git a/changes/16866-ade-force-filevault b/changes/16866-ade-force-filevault deleted file mode 100644 index 4486357bf5..0000000000 --- a/changes/16866-ade-force-filevault +++ /dev/null @@ -1,2 +0,0 @@ -- Adds enforcement of FileVault during the MacOS Setup Assistant process for hosts that are enrolled -into teams (or no team) with disk encryption turned on. \ No newline at end of file diff --git a/changes/17249-mysql-8 b/changes/17249-mysql-8 deleted file mode 100644 index b3948968cf..0000000000 --- a/changes/17249-mysql-8 +++ /dev/null @@ -1,2 +0,0 @@ -* Drop support for MySQL 5.7 -* Minimum requirements raised to MySQL 8.0 diff --git a/changes/17558-validation-errs b/changes/17558-validation-errs new file mode 100644 index 0000000000..115c9bf14e --- /dev/null +++ b/changes/17558-validation-errs @@ -0,0 +1,2 @@ +- Adds validation of Setup Assistant profiles on profile upload, giving users immediate feedback on +the validity of the profile. \ No newline at end of file diff --git a/changes/1845-linux-arm64 b/changes/1845-linux-arm64 deleted file mode 100644 index 6ebb53ff63..0000000000 --- a/changes/1845-linux-arm64 +++ /dev/null @@ -1,2 +0,0 @@ -* Added support for generating fleetd packages for Linux ARM64 -* fleetctl: New `fleetctl package` --arch flag diff --git a/changes/18897-shoe-zeroes b/changes/18897-shoe-zeroes new file mode 100644 index 0000000000..7faddd522d --- /dev/null +++ b/changes/18897-shoe-zeroes @@ -0,0 +1 @@ +Added "0 items" description on empty software tables for UI consistency diff --git a/changes/18913-ignore-rejected-cves b/changes/18913-ignore-rejected-cves deleted file mode 100644 index 1fabe60f9f..0000000000 --- a/changes/18913-ignore-rejected-cves +++ /dev/null @@ -1 +0,0 @@ -CVEs identified as 'Rejected' in NVD will no longer match against software \ No newline at end of file diff --git a/changes/19280-maintenance-window-descriptions b/changes/19280-maintenance-window-descriptions deleted file mode 100644 index 90848dcffe..0000000000 --- a/changes/19280-maintenance-window-descriptions +++ /dev/null @@ -1 +0,0 @@ -Maintenance window descriptions are now updated regularly to match the failing policy description/resolution. diff --git a/changes/19352-calendar-real-time b/changes/19352-calendar-real-time deleted file mode 100644 index d96cf1fa11..0000000000 --- a/changes/19352-calendar-real-time +++ /dev/null @@ -1,3 +0,0 @@ -- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past. - - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes. - - These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods. diff --git a/changes/19442-ubuntu-python-packages b/changes/19442-ubuntu-python-packages new file mode 100644 index 0000000000..0be7e95616 --- /dev/null +++ b/changes/19442-ubuntu-python-packages @@ -0,0 +1 @@ +- Addressing Ubuntu python package false positive vulnerabilities by removing duplicate entries for ubuntu python packages installed by dpkg and renaming remaining pip installed packages to match OVAL definitions. \ No newline at end of file diff --git a/changes/19447-ios-ipados-software b/changes/19447-ios-ipados-software deleted file mode 100644 index 26acad5131..0000000000 --- a/changes/19447-ios-ipados-software +++ /dev/null @@ -1,3 +0,0 @@ -- iOS and iPadOS device details refetch can now be triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint. -- iOS and iPadOS user-installed apps can be viewed in Fleet -- iOS and iPadOS apps can be installed using Apple's VPP (Volume Purchase Program) diff --git a/changes/19550-software-no-teams b/changes/19550-software-no-teams deleted file mode 100644 index 933665cd22..0000000000 --- a/changes/19550-software-no-teams +++ /dev/null @@ -1 +0,0 @@ -- adds support for No teams on all software pages including adding software installers \ No newline at end of file diff --git a/changes/19551-policy-software-automations b/changes/19551-policy-software-automations new file mode 100644 index 0000000000..4b88cb4c1f --- /dev/null +++ b/changes/19551-policy-software-automations @@ -0,0 +1 @@ +* Implement features allowing automatic installation of software on hosts that fail policies. diff --git a/changes/19646-ui-profiles-pending-tooltip b/changes/19646-ui-profiles-pending-tooltip deleted file mode 100644 index 824ba143c9..0000000000 --- a/changes/19646-ui-profiles-pending-tooltip +++ /dev/null @@ -1 +0,0 @@ -- Updated UI tooltips for pending OS settings. diff --git a/changes/19684-renew-scep-180 b/changes/19684-renew-scep-180 deleted file mode 100644 index 131c08ff51..0000000000 --- a/changes/19684-renew-scep-180 +++ /dev/null @@ -1 +0,0 @@ -* Increase threshold to renew Apple SCEP certificates for MDM enrollments to 180 days. diff --git a/changes/19808-prof b/changes/19808-prof new file mode 100644 index 0000000000..71d19f8c4b --- /dev/null +++ b/changes/19808-prof @@ -0,0 +1 @@ +* Fixed bugs on enrollment profiles when the organization name contains invalid XML characters. diff --git a/changes/19853-homebrew-intellij b/changes/19853-homebrew-intellij deleted file mode 100644 index 713d4ae142..0000000000 --- a/changes/19853-homebrew-intellij +++ /dev/null @@ -1 +0,0 @@ -Fixed false negative vulnerabilities with IntelliJ IDEA CE and PyCharm CE installed via Homebrew. diff --git a/changes/19864-vpp-token-crud b/changes/19864-vpp-token-crud deleted file mode 100644 index ee4a92e80f..0000000000 --- a/changes/19864-vpp-token-crud +++ /dev/null @@ -1,2 +0,0 @@ -- Adds the functionality for the `POST /mdm/apple/vpp_token`, `DELETE /mdm/apple/vpp_token` and -`GET /vpp` endpoints. \ No newline at end of file diff --git a/changes/19865-db-schema b/changes/19865-db-schema deleted file mode 100644 index ede5f90ed0..0000000000 --- a/changes/19865-db-schema +++ /dev/null @@ -1 +0,0 @@ -- Adds DB updates to support the VPP software feature. \ No newline at end of file diff --git a/changes/19867-get-avail-apps b/changes/19867-get-avail-apps deleted file mode 100644 index 4ace068f95..0000000000 --- a/changes/19867-get-avail-apps +++ /dev/null @@ -1 +0,0 @@ -- Adds functionality for the `GET /software/app_store_apps` and `POST /software/app_store_apps` endpoints. \ No newline at end of file diff --git a/changes/19868-vpp-install-command b/changes/19868-vpp-install-command deleted file mode 100644 index 337b5d5010..0000000000 --- a/changes/19868-vpp-install-command +++ /dev/null @@ -1 +0,0 @@ -- Adds functionality for installing App Store apps to the VPP feature. \ No newline at end of file diff --git a/changes/19870-vpp-activities-backend b/changes/19870-vpp-activities-backend deleted file mode 100644 index 115f92e1fd..0000000000 --- a/changes/19870-vpp-activities-backend +++ /dev/null @@ -1 +0,0 @@ -- Adds global activity support for VPP related activities. \ No newline at end of file diff --git a/changes/19871-gitops-vpp-config b/changes/19871-gitops-vpp-config deleted file mode 100644 index e9a02e0fa7..0000000000 --- a/changes/19871-gitops-vpp-config +++ /dev/null @@ -1 +0,0 @@ -* Add support for VPP to gitops config diff --git a/changes/19880-include-vpp-apps-in-software-titles-endpoints b/changes/19880-include-vpp-apps-in-software-titles-endpoints deleted file mode 100644 index 9503cdef99..0000000000 --- a/changes/19880-include-vpp-apps-in-software-titles-endpoints +++ /dev/null @@ -1,2 +0,0 @@ -* Added the associated VPP apps to the `GET /software/titles` and `GET /software/titles/:id` endpoints. -* Added the associated VPP apps to the `GET /hosts/:id/software` and `GET /device/:token/software` endpoints. diff --git a/changes/20042-remove-package-version b/changes/20042-remove-package-version deleted file mode 100644 index a4a5801417..0000000000 --- a/changes/20042-remove-package-version +++ /dev/null @@ -1 +0,0 @@ -In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. diff --git a/changes/20100-os-version-compliance b/changes/20100-os-version-compliance deleted file mode 100644 index f14334f97f..0000000000 --- a/changes/20100-os-version-compliance +++ /dev/null @@ -1 +0,0 @@ -- Fleet UI: Show OS version compliance on Host Details page diff --git a/changes/20271-deleted-host-software-installs b/changes/20271-deleted-host-software-installs deleted file mode 100644 index 674b8a823f..0000000000 --- a/changes/20271-deleted-host-software-installs +++ /dev/null @@ -1 +0,0 @@ -- Fig bug where software install results could not be retrieved for deleted hosts in the activity feed diff --git a/changes/20278-vpp-batch-api b/changes/20278-vpp-batch-api deleted file mode 100644 index e5cbbf7eca..0000000000 --- a/changes/20278-vpp-batch-api +++ /dev/null @@ -1 +0,0 @@ -- GitOps supports VPP app associations diff --git a/changes/20320-uninstall-packages b/changes/20320-uninstall-packages new file mode 100644 index 0000000000..89ab892841 --- /dev/null +++ b/changes/20320-uninstall-packages @@ -0,0 +1 @@ +* Implement the ability to use Fleet to uninstall packages from hosts. \ No newline at end of file diff --git a/changes/20370-linux-nologin b/changes/20370-linux-nologin deleted file mode 100644 index 236418c963..0000000000 --- a/changes/20370-linux-nologin +++ /dev/null @@ -1 +0,0 @@ -- Linux lock/unlock scripts now make use of pam_nologin to keep AD users locked out diff --git a/changes/20397-do-not-set-last_enrolled_at-when-enrolling-orbit b/changes/20397-do-not-set-last_enrolled_at-when-enrolling-orbit deleted file mode 100644 index c8f305c4d1..0000000000 --- a/changes/20397-do-not-set-last_enrolled_at-when-enrolling-orbit +++ /dev/null @@ -1 +0,0 @@ -* Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . diff --git a/changes/20404-edit-software b/changes/20404-edit-software new file mode 100644 index 0000000000..ec65b392b4 --- /dev/null +++ b/changes/20404-edit-software @@ -0,0 +1 @@ +* Software installer packages, self-service flag, scripts, pre-install query, and self-service availability can now be edited in-place rather than needing to be deleted and re-added. diff --git a/changes/20440-Notion-exe-installer-name b/changes/20440-Notion-exe-installer-name deleted file mode 100644 index bc3996cc5d..0000000000 --- a/changes/20440-Notion-exe-installer-name +++ /dev/null @@ -1 +0,0 @@ -* Added a special-case to properly name the Notion .exe Windows installer the same as how it will be reported by osquery post-install. diff --git a/changes/20467-vpp-ipadios-ui b/changes/20467-vpp-ipadios-ui deleted file mode 100644 index 2cc84e31cd..0000000000 --- a/changes/20467-vpp-ipadios-ui +++ /dev/null @@ -1 +0,0 @@ -* Add UI features for managing Apple VPP apps for iPadOS and iOS hosts \ No newline at end of file diff --git a/changes/20469-backend-ios-ipados-os-updates b/changes/20469-backend-ios-ipados-os-updates deleted file mode 100644 index 075cca4876..0000000000 --- a/changes/20469-backend-ios-ipados-os-updates +++ /dev/null @@ -1 +0,0 @@ -* Adding OS updates support to iOS/iPadOS devices. diff --git a/changes/20515-delete-vpp-app b/changes/20515-delete-vpp-app deleted file mode 100644 index 49599edf94..0000000000 --- a/changes/20515-delete-vpp-app +++ /dev/null @@ -1,2 +0,0 @@ -* Added support to delete a VPP app from a team in `DELETE /software/titles/:software_title_id/available_for_install`. -* Fixed path that was incorrect for the download software installer package endpoint `GET /software/titles/:software_title_id/package`. diff --git a/changes/20535-sw-table-loading b/changes/20535-sw-table-loading new file mode 100644 index 0000000000..d144ce782c --- /dev/null +++ b/changes/20535-sw-table-loading @@ -0,0 +1 @@ +* Improve loading state for DataTables when no data is present yet \ No newline at end of file diff --git a/changes/20575-fix-profile-activities-to-include-ios-ipados b/changes/20575-fix-profile-activities-to-include-ios-ipados deleted file mode 100644 index bf089bf489..0000000000 --- a/changes/20575-fix-profile-activities-to-include-ios-ipados +++ /dev/null @@ -1 +0,0 @@ -- Update profile activities to include iOS and iPadOS diff --git a/changes/20604-hosts-page-pagination b/changes/20604-hosts-page-pagination deleted file mode 100644 index c1f68d5f94..0000000000 --- a/changes/20604-hosts-page-pagination +++ /dev/null @@ -1 +0,0 @@ -* Fix a bug where hosts page would sometimes allow excess pagination \ No newline at end of file diff --git a/changes/20618-nil-tz-not-handled b/changes/20618-nil-tz-not-handled deleted file mode 100644 index cbb5d0bd99..0000000000 --- a/changes/20618-nil-tz-not-handled +++ /dev/null @@ -1,2 +0,0 @@ -* Fix a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly - processed by 4.54.0 \ No newline at end of file diff --git a/changes/20683-less-columns-smaller-width b/changes/20683-less-columns-smaller-width new file mode 100644 index 0000000000..c2e03aedde --- /dev/null +++ b/changes/20683-less-columns-smaller-width @@ -0,0 +1 @@ +- UI cleanup: Host details about section condenses information into fewer columns at smaller widths diff --git a/changes/20747-gitops-software-query b/changes/20747-gitops-software-query deleted file mode 100644 index 100efc17f3..0000000000 --- a/changes/20747-gitops-software-query +++ /dev/null @@ -1 +0,0 @@ -- Use new gitops format for software pre install query diff --git a/changes/20751-detect-held-linux-packages-as-installed b/changes/20751-detect-held-linux-packages-as-installed deleted file mode 100644 index 6aa524ce80..0000000000 --- a/changes/20751-detect-held-linux-packages-as-installed +++ /dev/null @@ -1 +0,0 @@ -Linux .deb packages 'on hold' are now included in the installed software list. diff --git a/changes/20757-profiles-batch-activity b/changes/20757-profiles-batch-activity new file mode 100644 index 0000000000..6b110b87c7 --- /dev/null +++ b/changes/20757-profiles-batch-activity @@ -0,0 +1 @@ +API endpoint `/api/v1/fleet/mdm/profiles/batch` will now not log an activity for profile types that did not change in the database (Apple configuration profiles, Windows configuration profiles, or Apple declarations). diff --git a/changes/20764-fix-cron-with-duplicate-host-uuid-windows-mdm b/changes/20764-fix-cron-with-duplicate-host-uuid-windows-mdm new file mode 100644 index 0000000000..df19c08bc8 --- /dev/null +++ b/changes/20764-fix-cron-with-duplicate-host-uuid-windows-mdm @@ -0,0 +1 @@ +* Fixed an issue where cron profiles delivery fails if a Windows VM is enrolled twice with the same `host_uuid` / `mdm_device_id`. diff --git a/changes/20828-better-appid-error b/changes/20828-better-appid-error new file mode 100644 index 0000000000..540c8fcbfa --- /dev/null +++ b/changes/20828-better-appid-error @@ -0,0 +1 @@ +- Improve clarity of gitops VPP app ID type errors diff --git a/changes/20846-vuln-virtual-box b/changes/20846-vuln-virtual-box new file mode 100644 index 0000000000..225dd0be22 --- /dev/null +++ b/changes/20846-vuln-virtual-box @@ -0,0 +1 @@ +- resolved an issue where virtual box for macOS wasn't matching against the vm_virtualbox NVD product name \ No newline at end of file diff --git a/changes/20865-fix-chrome-icon b/changes/20865-fix-chrome-icon new file mode 100644 index 0000000000..9ac53c39cc --- /dev/null +++ b/changes/20865-fix-chrome-icon @@ -0,0 +1 @@ +- show proper software icon for chrome packages diff --git a/changes/20868-turn-off-mdm b/changes/20868-turn-off-mdm new file mode 100644 index 0000000000..bfcd35d315 --- /dev/null +++ b/changes/20868-turn-off-mdm @@ -0,0 +1 @@ +- Improves the UX of turning off MDM on an offline host (endpoint doesn't error anymore) \ No newline at end of file diff --git a/changes/20895-policy-software-install-gitops b/changes/20895-policy-software-install-gitops new file mode 100644 index 0000000000..774f6a4bfe --- /dev/null +++ b/changes/20895-policy-software-install-gitops @@ -0,0 +1 @@ +* Added support for configuring policy installers via GitOps. diff --git a/changes/20959-query-host-flow-fix-observer b/changes/20959-query-host-flow-fix-observer new file mode 100644 index 0000000000..f1db67c3e9 --- /dev/null +++ b/changes/20959-query-host-flow-fix-observer @@ -0,0 +1 @@ +- Fix UI flow for observers to easily query hosts from the host details page diff --git a/changes/21019-ota-enrollment b/changes/21019-ota-enrollment new file mode 100644 index 0000000000..b43db060a7 --- /dev/null +++ b/changes/21019-ota-enrollment @@ -0,0 +1 @@ +* Implement protocol support for OTA enrollment and automatic team assignment for hosts. diff --git a/changes/21264-fix-reserved-team-names b/changes/21264-fix-reserved-team-names new file mode 100644 index 0000000000..6363b81869 --- /dev/null +++ b/changes/21264-fix-reserved-team-names @@ -0,0 +1,2 @@ +- Prevents teams with the name "All teams" or "No team" from being created (these are reserved team + names in Fleet). \ No newline at end of file diff --git a/changes/21315-vpp-premium-license b/changes/21315-vpp-premium-license new file mode 100644 index 0000000000..2fd081703e --- /dev/null +++ b/changes/21315-vpp-premium-license @@ -0,0 +1 @@ +- Verify user has premium license before uploading VPP tokens diff --git a/changes/21402-improve-windows-mdm-enabled-error-message b/changes/21402-improve-windows-mdm-enabled-error-message new file mode 100644 index 0000000000..36dc6082f6 --- /dev/null +++ b/changes/21402-improve-windows-mdm-enabled-error-message @@ -0,0 +1 @@ +- Improve gitops error message about enabling windows MDM diff --git a/changes/21404-minio-false-positive b/changes/21404-minio-false-positive new file mode 100644 index 0000000000..57b4245e45 --- /dev/null +++ b/changes/21404-minio-false-positive @@ -0,0 +1 @@ +- resolved issue where minio was reporting false positive vulnerabilities due to a mismatch in version strings \ No newline at end of file diff --git a/changes/21412-remove-node-key-from-server-logs b/changes/21412-remove-node-key-from-server-logs new file mode 100644 index 0000000000..c6555bd5bc --- /dev/null +++ b/changes/21412-remove-node-key-from-server-logs @@ -0,0 +1 @@ +* Removed invalid node keys from server logs. diff --git a/changes/21428-policy-automatic-install-software b/changes/21428-policy-automatic-install-software new file mode 100644 index 0000000000..e61dc2a9ea --- /dev/null +++ b/changes/21428-policy-automatic-install-software @@ -0,0 +1 @@ +* Added automatic installation of software packages using policy automations. diff --git a/changes/21428-prevent-install-when-already-pending b/changes/21428-prevent-install-when-already-pending new file mode 100644 index 0000000000..d01006d6f9 --- /dev/null +++ b/changes/21428-prevent-install-when-already-pending @@ -0,0 +1 @@ +* Added validation to `POST /api/_version_/fleet/hosts/{host_id}/software/install/{software_title_id}` to prevent installing on a host that already has a pending installation for that software title. diff --git a/changes/21462-host-vulnerability-filter b/changes/21462-host-vulnerability-filter new file mode 100644 index 0000000000..e55fb8c836 --- /dev/null +++ b/changes/21462-host-vulnerability-filter @@ -0,0 +1 @@ +- fixed issue where the vulnerability filter was returning software not vulnerable for the currently selected host \ No newline at end of file diff --git a/changes/21467-policies-for-no-team b/changes/21467-policies-for-no-team new file mode 100644 index 0000000000..4613cd39ed --- /dev/null +++ b/changes/21467-policies-for-no-team @@ -0,0 +1 @@ +* Added support for policies in "No team" that run on hosts that belong to "No team". diff --git a/changes/21468-no-teams-policies b/changes/21468-no-teams-policies new file mode 100644 index 0000000000..d11adda1b8 --- /dev/null +++ b/changes/21468-no-teams-policies @@ -0,0 +1 @@ +* Enable 'No teams' funcitonality for the policies page and associated workflows. \ No newline at end of file diff --git a/changes/21557-ota-profile-endpoint b/changes/21557-ota-profile-endpoint new file mode 100644 index 0000000000..4acf2bbcf5 --- /dev/null +++ b/changes/21557-ota-profile-endpoint @@ -0,0 +1 @@ +- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts. \ No newline at end of file diff --git a/changes/21559-add-end-user-enrolment-page b/changes/21559-add-end-user-enrolment-page new file mode 100644 index 0000000000..427f1c5beb --- /dev/null +++ b/changes/21559-add-end-user-enrolment-page @@ -0,0 +1 @@ +- add feature for end users to enroll their device into fleet mdm diff --git a/changes/21612-edit-software-gitops b/changes/21612-edit-software-gitops new file mode 100644 index 0000000000..9a157286d4 --- /dev/null +++ b/changes/21612-edit-software-gitops @@ -0,0 +1 @@ +* Reset install counts and cancel pending installs/uninstalls when GitOps installer updates change package contents diff --git a/changes/21683-apns-cert-validation-on-start b/changes/21683-apns-cert-validation-on-start new file mode 100644 index 0000000000..9f17143599 --- /dev/null +++ b/changes/21683-apns-cert-validation-on-start @@ -0,0 +1,2 @@ +- Removed validation of APNS certificate from server startup. This was no longer necessary because + we now allow for APNS certificates to be renewed in the UI. diff --git a/changes/21779-git-false-negative b/changes/21779-git-false-negative new file mode 100644 index 0000000000..080dfe1a4e --- /dev/null +++ b/changes/21779-git-false-negative @@ -0,0 +1 @@ +- fixed a false negative vulnerability for git \ No newline at end of file diff --git a/changes/21796-fix-vpp-self-service-checkbox b/changes/21796-fix-vpp-self-service-checkbox new file mode 100644 index 0000000000..6ec7e46db9 --- /dev/null +++ b/changes/21796-fix-vpp-self-service-checkbox @@ -0,0 +1 @@ +- Fixed self-service checkbox appearing when iOS or iPadOS app is selected. diff --git a/changes/21813-email-err b/changes/21813-email-err new file mode 100644 index 0000000000..a9d25ecc21 --- /dev/null +++ b/changes/21813-email-err @@ -0,0 +1,2 @@ +- Fixed regression: we now check if the email used to get a signed CSR is invalid (i.e. is an email + from a free email provider). \ No newline at end of file diff --git a/changes/21866-startup-expired-abm-cert b/changes/21866-startup-expired-abm-cert new file mode 100644 index 0000000000..f9e74bb641 --- /dev/null +++ b/changes/21866-startup-expired-abm-cert @@ -0,0 +1,2 @@ +- Fixed issue where Fleet server could start when expired ABM cerfificate was provided as server + config options. diff --git a/changes/21890-vpp-token-error b/changes/21890-vpp-token-error new file mode 100644 index 0000000000..da03734eca --- /dev/null +++ b/changes/21890-vpp-token-error @@ -0,0 +1 @@ +- Improve messaging for VPP token constraint errors diff --git a/changes/21891-mdm-profile-fails b/changes/21891-mdm-profile-fails new file mode 100644 index 0000000000..a01bac1649 --- /dev/null +++ b/changes/21891-mdm-profile-fails @@ -0,0 +1,2 @@ +- Fixes a bug where a profile wouldn't be removed from a host if it was deleted or if the host was + moved to another team before the profile was installed on the host. \ No newline at end of file diff --git a/changes/21976-update-macos-target-version-tooltip b/changes/21976-update-macos-target-version-tooltip new file mode 100644 index 0000000000..5ae1a5ffdf --- /dev/null +++ b/changes/21976-update-macos-target-version-tooltip @@ -0,0 +1 @@ +- update the macos target minimum version tooltip diff --git a/changes/22069-gitops-async-software-batch b/changes/22069-gitops-async-software-batch new file mode 100644 index 0000000000..35f0652fe2 --- /dev/null +++ b/changes/22069-gitops-async-software-batch @@ -0,0 +1 @@ +* Modified `POST /api/latest/fleet/software/batch` endpoint to be asynchronous and added a new endpoint `GET /api/latest/fleet/software/batch/{request_uuid}` to retrieve the result of the batch upload. diff --git a/changes/22097-mdm-migration-guide b/changes/22097-mdm-migration-guide new file mode 100644 index 0000000000..0177cf49b6 --- /dev/null +++ b/changes/22097-mdm-migration-guide @@ -0,0 +1 @@ +- Updates the guide for MDM migration to include the new UX in fleetd. \ No newline at end of file diff --git a/changes/22106-fix-software-package-name b/changes/22106-fix-software-package-name new file mode 100644 index 0000000000..3cd87a328e --- /dev/null +++ b/changes/22106-fix-software-package-name @@ -0,0 +1 @@ +- Fixed UI design bug where software package file name was not displayed as expected. diff --git a/changes/22136-software-status-no-teams-hosts-page b/changes/22136-software-status-no-teams-hosts-page new file mode 100644 index 0000000000..6ede268471 --- /dev/null +++ b/changes/22136-software-status-no-teams-hosts-page @@ -0,0 +1 @@ +* Support the software status filter for 'No teams' on the hosts page \ No newline at end of file diff --git a/changes/22158-scep b/changes/22158-scep new file mode 100644 index 0000000000..ab75574680 --- /dev/null +++ b/changes/22158-scep @@ -0,0 +1 @@ +* Allow custom SCEP CA certificates with any kind of extendedKeyUsage attributes. diff --git a/changes/7476-fix-ui-overflow-os-settings-table b/changes/7476-fix-ui-overflow-os-settings-table new file mode 100644 index 0000000000..6c95925de8 --- /dev/null +++ b/changes/7476-fix-ui-overflow-os-settings-table @@ -0,0 +1 @@ +- fixes UI overflow issues with OS settings table data diff --git a/changes/api-get-host-by-identifier-exclude-software b/changes/api-get-host-by-identifier-exclude-software deleted file mode 100644 index aa2aa5404a..0000000000 --- a/changes/api-get-host-by-identifier-exclude-software +++ /dev/null @@ -1 +0,0 @@ -- add exclude_software query paramter to "Get host by identifier" API \ No newline at end of file diff --git a/changes/apns-errors b/changes/apns-errors new file mode 100644 index 0000000000..6de48617a1 --- /dev/null +++ b/changes/apns-errors @@ -0,0 +1 @@ +* Fixed logic to properly catch and log APNs errors. diff --git a/changes/hosts-can-access-any-software b/changes/hosts-can-access-any-software new file mode 100644 index 0000000000..0fbcae035a --- /dev/null +++ b/changes/hosts-can-access-any-software @@ -0,0 +1 @@ +- Hosts can no longer access installers that aren't directly assigned to it diff --git a/changes/issue-19691-add-vpp-token-expiry-banner b/changes/issue-19691-add-vpp-token-expiry-banner deleted file mode 100644 index d4f14c98c6..0000000000 --- a/changes/issue-19691-add-vpp-token-expiry-banner +++ /dev/null @@ -1 +0,0 @@ -- add a warning banner to the UI if the uploaded VPP token is about to expire/has expired. diff --git a/changes/issue-19866-add-remove-disable-vpp-in-ui b/changes/issue-19866-add-remove-disable-vpp-in-ui deleted file mode 100644 index 09000dbff2..0000000000 --- a/changes/issue-19866-add-remove-disable-vpp-in-ui +++ /dev/null @@ -1 +0,0 @@ -- add ability to add/remove/disable vpp in the fleet UI. diff --git a/changes/issue-19869-vpp-ui-on-software-pages b/changes/issue-19869-vpp-ui-on-software-pages deleted file mode 100644 index 74f71d41c9..0000000000 --- a/changes/issue-19869-vpp-ui-on-software-pages +++ /dev/null @@ -1 +0,0 @@ -- add UI to support the apple vpp feature on the software pages. diff --git a/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp b/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp deleted file mode 100644 index 01e6073b2d..0000000000 --- a/changes/issue-20612-ui-updates-host-software-device-user-pages-for-vpp +++ /dev/null @@ -1 +0,0 @@ -- add UI updates for VPP feature on host software and my device pages. diff --git a/changes/issue-20784-fix-app-wide-banner-showing b/changes/issue-20784-fix-app-wide-banner-showing deleted file mode 100644 index 9720e4b20b..0000000000 --- a/changes/issue-20784-fix-app-wide-banner-showing +++ /dev/null @@ -1 +0,0 @@ -- fix an issue where the app-wide warning banners were not showing on the initial page load diff --git a/changes/update-go1.23.1 b/changes/update-go1.23.1 new file mode 100644 index 0000000000..22a59cdc40 --- /dev/null +++ b/changes/update-go1.23.1 @@ -0,0 +1 @@ +* Updated Go to go1.23.1 diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index 6921cfc2f0..adc22108c2 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,7 +8,7 @@ version: v6.2.0 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.54.1 +appVersion: v4.56.0 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/templates/_helpers.tpl b/charts/fleet/templates/_helpers.tpl index f6c41ef3fb..fd0cfffbf9 100644 --- a/charts/fleet/templates/_helpers.tpl +++ b/charts/fleet/templates/_helpers.tpl @@ -23,6 +23,11 @@ If release name contains chart name it will be used as a full name. {{- end }} {{- end }} +{{- define "fleet.servicename" -}} +{{- $fullName := include "fleet.fullname" . -}} +{{- printf "%s-service" $fullName }} +{{- end }} + {{/* Create chart name and version as used by the chart label. */}} diff --git a/charts/fleet/templates/ingress.yaml b/charts/fleet/templates/ingress.yaml index 0a7326d995..1a3e758a29 100644 --- a/charts/fleet/templates/ingress.yaml +++ b/charts/fleet/templates/ingress.yaml @@ -1,5 +1,6 @@ {{- if .Values.ingress.enabled -}} {{- $fullName := include "fleet.fullname" . -}} +{{- $serviceName := include "fleet.servicename" . -}} {{- $svcPort := ternary .Values.fleet.listenPort .Values.fleet.servicePort (eq .Values.fleet.servicePort nil) -}} {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} @@ -49,11 +50,11 @@ spec: backend: {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} service: - name: {{ $fullName }} + name: {{ $serviceName }} port: number: {{ $svcPort }} {{- else }} - serviceName: {{ $fullName }} + serviceName: {{ $serviceName }} servicePort: {{ $svcPort }} {{- end }} {{- end }} diff --git a/charts/fleet/templates/job-migration.yaml b/charts/fleet/templates/job-migration.yaml index 8226e3d29a..135dac9a63 100644 --- a/charts/fleet/templates/job-migration.yaml +++ b/charts/fleet/templates/job-migration.yaml @@ -18,6 +18,7 @@ metadata: "helm.sh/hook-delete-policy": hook-succeeded {{- end }} spec: + ttlSecondsAfterFinished: 100 template: metadata: {{- with .Values.podAnnotations }} diff --git a/charts/fleet/templates/service.yaml b/charts/fleet/templates/service.yaml index 1a22e48fc0..fa1c13a94d 100644 --- a/charts/fleet/templates/service.yaml +++ b/charts/fleet/templates/service.yaml @@ -1,3 +1,4 @@ +{{- $serviceName := include "fleet.servicename" . -}} apiVersion: v1 kind: Service metadata: @@ -6,7 +7,7 @@ metadata: chart: fleet heritage: {{ .Release.Service }} release: {{ .Release.Name }} - name: fleet + name: {{ $serviceName }} namespace: {{ .Release.Namespace }} spec: {{- if .Values.gke.ingress.useGKEIngress }} diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index 0f8e570d09..040a539a83 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -3,7 +3,7 @@ hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy imageRepository: fleetdm/fleet -imageTag: v4.54.1 # Version of Fleet to deploy +imageTag: v4.56.0 # 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: @@ -212,7 +212,7 @@ environments: # The following environment variable is required if you are using # Fleet's macOS MDM features. In a production environment, it is recommended that # you store this private key in a secret and use envsFrom to reference the secret below. - # To more information: https://fleetdm.com/docs/using-fleet/fleet-server-configuration#server-private-key + # For more information, check out the docs: https://fleetdm.com/docs/configuration/fleet-server-configuration#server-private-key FLEET_SERVER_PRIVATE_KEY: "" ## Section: Environment Variables from Secrets/CMs diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 0cdcd66277..3b42a53567 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -11,6 +11,7 @@ import ( "strings" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" eewebhooks "github.com/fleetdm/fleet/v4/ee/server/webhooks" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/config" @@ -28,6 +29,7 @@ import ( "github.com/fleetdm/fleet/v4/server/service/externalsvc" "github.com/fleetdm/fleet/v4/server/service/schedule" "github.com/fleetdm/fleet/v4/server/vulnerabilities/customcve" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/goval_dictionary" "github.com/fleetdm/fleet/v4/server/vulnerabilities/macoffice" "github.com/fleetdm/fleet/v4/server/vulnerabilities/msrc" "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd" @@ -37,7 +39,6 @@ import ( "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" - "github.com/google/uuid" "github.com/hashicorp/go-multierror" ) @@ -159,6 +160,7 @@ func scanVulnerabilities( nvdVulns := checkNVDVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") ovalVulns := checkOvalVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") + govalDictVulns := checkGovalDictionaryVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") macOfficeVulns := checkMacOfficeVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") customVulns := checkCustomVulnerabilities(ctx, ds, logger, config, vulnAutomationEnabled != "") @@ -173,6 +175,7 @@ func scanVulnerabilities( vulns = append(vulns, nvdVulns...) vulns = append(vulns, ovalVulns...) vulns = append(vulns, macOfficeVulns...) + vulns = append(vulns, govalDictVulns...) vulns = append(vulns, customVulns...) meta, err := ds.ListCVEs(ctx, config.RecentVulnerabilityMaxAge) @@ -345,6 +348,11 @@ func checkOvalVulnerabilities( for _, version := range versions.OSVersions { start := time.Now() r, err := oval.Analyze(ctx, ds, version, vulnPath, collectVulns) + if err != nil && errors.Is(err, oval.ErrUnsupportedPlatform) { + level.Debug(logger).Log("msg", "oval-analysis-unsupported", "platform", version.Name) + continue + } + elapsed := time.Since(start) level.Debug(logger).Log( "msg", "oval-analysis-done", @@ -360,6 +368,57 @@ func checkOvalVulnerabilities( return results } +func checkGovalDictionaryVulnerabilities( + ctx context.Context, + ds fleet.Datastore, + logger kitlog.Logger, + vulnPath string, + config *config.VulnerabilitiesConfig, + collectVulns bool, +) []fleet.SoftwareVulnerability { + var results []fleet.SoftwareVulnerability + + // Get Platforms + versions, err := ds.OSVersions(ctx, nil, nil, nil, nil) + if err != nil { + errHandler(ctx, logger, "listing platforms for goval_dictionary pulls", err) + return nil + } + + if !config.DisableDataSync { + // Sync on disk goval_dictionary sqlite with current OS Versions. + downloaded, err := goval_dictionary.Refresh(versions, vulnPath, logger) + if err != nil { + errHandler(ctx, logger, "updating goval_dictionary databases", err) + } + for _, d := range downloaded { + level.Debug(logger).Log("goval_dictionary-sync-downloaded", d) + } + } + + // Analyze all supported os versions using the synced goval_dictionary definitions. + for _, version := range versions.OSVersions { + start := time.Now() + r, err := goval_dictionary.Analyze(ctx, ds, version, vulnPath, collectVulns, logger) + if err != nil && errors.Is(err, goval_dictionary.ErrUnsupportedPlatform) { + level.Debug(logger).Log("msg", "goval_dictionary-analysis-unsupported", "platform", version.Name) + continue + } + elapsed := time.Since(start) + level.Debug(logger).Log( + "msg", "goval_dictionary-analysis-done", + "platform", version.Name, + "elapsed", elapsed, + "found new", len(r)) + results = append(results, r...) + if err != nil { + errHandler(ctx, logger, "analyzing goval_dictionary definitions", err) + } + } + + return results +} + func checkNVDVulnerabilities( ctx context.Context, ds fleet.Datastore, @@ -625,7 +684,11 @@ func newWorkerIntegrationsSchedule( Log: logger, Commander: commander, } - w.Register(jira, zendesk, macosSetupAsst, appleMDM) + dbMigrate := &worker.DBMigration{ + Datastore: ds, + Log: logger, + } + w.Register(jira, zendesk, macosSetupAsst, appleMDM, dbMigrate) // Read app config a first time before starting, to clear up any failer client // configuration if we're not on a fleet-owned server. Technically, the ServerURL @@ -735,6 +798,7 @@ func newCleanupsAndAggregationSchedule( config *config.FleetConfig, commander *apple_mdm.MDMAppleCommander, softwareInstallStore fleet.SoftwareInstallerStore, + bootstrapPackageStore fleet.MDMBootstrapPackageStore, ) (*schedule.Schedule, error) { const ( name = string(fleet.CronCleanupsThenAggregation) @@ -882,7 +946,19 @@ func newCleanupsAndAggregationSchedule( return ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, appConfig.ActivityExpirySettings.ActivityExpiryWindow) }), schedule.WithJob("cleanup_unused_software_installers", func(ctx context.Context) error { - return ds.CleanupUnusedSoftwareInstallers(ctx, softwareInstallStore) + // remove only those unused created more than a minute ago to avoid a + // race where we delete those created after the mysql query to get those + // in use. + return ds.CleanupUnusedSoftwareInstallers(ctx, softwareInstallStore, time.Now().Add(-time.Minute)) + }), + schedule.WithJob("cleanup_unused_bootstrap_packages", func(ctx context.Context) error { + // remove only those unused created more than a minute ago to avoid a + // race where we delete those created after the mysql query to get those + // in use. + return ds.CleanupUnusedBootstrapPackages(ctx, bootstrapPackageStore, time.Now().Add(-time.Minute)) + }), + schedule.WithJob("cleanup_host_mdm_commands", func(ctx context.Context) error { + return ds.CleanupHostMDMCommands(ctx) }), ) @@ -936,7 +1012,6 @@ func verifyDiskEncryptionKeys( logger kitlog.Logger, ds fleet.Datastore, ) error { - appCfg, err := ds.AppConfig(ctx) if err != nil { logger.Log("err", "unable to get app config", "details", err) @@ -1050,31 +1125,59 @@ func newAppleMDMDEPProfileAssigner( ) (*schedule.Schedule, error) { const name = string(fleet.CronAppleMDMDEPProfileAssigner) logger = kitlog.With(logger, "cron", name, "component", "nanodep-syncer") - var fleetSyncer *apple_mdm.DEPService s := schedule.New( ctx, name, instanceID, periodicity, ds, ds, schedule.WithLogger(logger), - schedule.WithJob("dep_syncer", func(ctx context.Context) error { - appCfg, err := ds.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "retrieving app config") - } - - if !appCfg.MDM.AppleBMEnabledAndConfigured { - return nil - } - - if fleetSyncer == nil { - fleetSyncer = apple_mdm.NewDEPService(ds, depStorage, logger) - } - - return fleetSyncer.RunAssigner(ctx) - }), + schedule.WithJob("dep_syncer", appleMDMDEPSyncerJob(ds, depStorage, logger)), ) return s, nil } +func appleMDMDEPSyncerJob( + ds fleet.Datastore, + depStorage *mysql.NanoDEPStorage, + logger kitlog.Logger, +) func(context.Context) error { + var fleetSyncer *apple_mdm.DEPService + return func(ctx context.Context) error { + appCfg, err := ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving app config") + } + + if !appCfg.MDM.AppleBMEnabledAndConfigured { + return nil + } + + // As part of the DB migration of the single ABM token to the multi-ABM + // token world (where the token was migrated from mdm_config_assets to + // abm_tokens), we need to complete migration of the existing token as + // during the DB migration we didn't have the organization name, apple id + // and renewal date. + incompleteToken, err := ds.GetABMTokenByOrgName(ctx, "") + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "retrieving migrated ABM token") + } + if incompleteToken != nil { + logger.Log("msg", "migrated ABM token found, updating its metadata") + if err := apple_mdm.SetABMTokenMetadata(ctx, incompleteToken, depStorage, ds, logger); err != nil { + return ctxerr.Wrap(ctx, err, "updating migrated ABM token metadata") + } + if err := ds.SaveABMToken(ctx, incompleteToken); err != nil { + return ctxerr.Wrap(ctx, err, "saving updated migrated ABM token") + } + logger.Log("msg", "completed migration of existing ABM token") + } + + if fleetSyncer == nil { + fleetSyncer = apple_mdm.NewDEPService(ds, depStorage, logger) + } + + return fleetSyncer.RunAssigner(ctx) + } +} + func newMDMProfileManager( ctx context.Context, instanceID string, @@ -1115,17 +1218,16 @@ func newMDMAPNsPusher( commander *apple_mdm.MDMAppleCommander, logger kitlog.Logger, ) (*schedule.Schedule, error) { - const name = string(fleet.CronAppleMDMAPNsPusher) - var interval = 1 * time.Minute + interval := 1 * time.Minute if intervalEnv := os.Getenv("FLEET_DEV_CUSTOM_APNS_PUSHER_INTERVAL"); intervalEnv != "" { var err error interval, err = time.ParseDuration(intervalEnv) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "invalid duration provided in env var FLEET_DEV_CUSTOM_APNS_PUSHER_INTERVAL") + level.Warn(logger).Log("msg", "invalid duration provided for FLEET_DEV_CUSTOM_APNS_PUSHER_INTERVAL, using default interval") + interval = 1 * time.Minute } - } logger = kitlog.With(logger, "cron", name) @@ -1285,37 +1387,34 @@ func newIPhoneIPadRefetcher( ctx, name, instanceID, periodicity, ds, ds, schedule.WithLogger(logger), schedule.WithJob("cron_iphone_ipad_refetcher", func(ctx context.Context) error { - appCfg, err := ds.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "fetching app config") - } - - if !appCfg.MDM.EnabledAndConfigured { - level.Debug(logger).Log("msg", "apple mdm is not configured, skipping run") - return nil - } - - start := time.Now() - uuids, err := ds.ListIOSAndIPadOSToRefetch(ctx, 1*time.Hour) - if err != nil { - return ctxerr.Wrap(ctx, err, "list ios and ipad devices to refetch") - } - if len(uuids) == 0 { - return nil - } - logger.Log("msg", "sending commands to refetch", "count", len(uuids), "lookup-duration", time.Since(start)) - commandUUID := fleet.RefetchCommandUUIDPrefix + uuid.NewString() - err = commander.InstalledApplicationList(ctx, uuids, fleet.RefetchAppsCommandUUIDPrefix+commandUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "send InstalledApplicationList commands to ios and ipados devices") - } - // DeviceInformation is last because the refetch response clears the refetch_requested flag - if err := commander.DeviceInformation(ctx, uuids, fleet.RefetchCommandUUIDPrefix+commandUUID); err != nil { - return ctxerr.Wrap(ctx, err, "send DeviceInformation commands to ios and ipados devices") - } - return nil + return apple_mdm.IOSiPadOSRefetch(ctx, ds, commander, logger) }), ) return s, nil } + +// cronUninstallSoftwareMigration will update uninstall scripts for software. +// Once all customers are using on Fleet 4.57 or later, this job can be removed. +func cronUninstallSoftwareMigration( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + softwareInstallStore fleet.SoftwareInstallerStore, + logger kitlog.Logger, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronUninstallSoftwareMigration) + defaultInterval = 24 * time.Hour + ) + logger = kitlog.With(logger, "cron", name, "component", name) + s := schedule.New( + ctx, name, instanceID, defaultInterval, ds, ds, + schedule.WithLogger(logger), + schedule.WithRunOnce(true), + schedule.WithJob(name, func(ctx context.Context) error { + return eeservice.UninstallSoftwareMigration(ctx, ds, softwareInstallStore, logger) + }), + ) + return s, nil +} diff --git a/cmd/fleet/cron_test.go b/cmd/fleet/cron_test.go index dd31dd2bec..789b38c405 100644 --- a/cmd/fleet/cron_test.go +++ b/cmd/fleet/cron_test.go @@ -2,13 +2,24 @@ package main import ( "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/require" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mock" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/go-kit/log" kitlog "github.com/go-kit/log" ) @@ -23,3 +34,93 @@ func TestNewMDMProfileManagerWithoutConfig(t *testing.T) { require.NotNil(t, sch) require.NoError(t, err) } + +func TestMigrateABMTokenDuringDEPCronJob(t *testing.T) { + ctx := context.Background() + ds := mysql.CreateMySQLDS(t) + + depStorage, err := ds.NewMDMAppleDEPStorage() + require.NoError(t, err) + // to avoid issues with syncer, use that constant as org name for now + const tokenOrgName = "fleet" + + // insert an ABM token as if it had been migrated by the DB migration script + tok := mysql.SetTestABMAssets(t, ds, "") + // tok, err := ds.InsertABMToken(ctx, &fleet.ABMToken{EncryptedToken: abmToken, RenewAt: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)}) + // require.NoError(t, err) + require.Empty(t, tok.OrganizationName) + + // start a server that will mock the Apple DEP API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, tokenOrgName))) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}) + require.NoError(t, err) + case "/server/devices": + err := encoder.Encode(godep.DeviceResponse{Devices: nil}) + require.NoError(t, err) + case "/devices/sync": + err := encoder.Encode(godep.DeviceResponse{Devices: nil}) + require.NoError(t, err) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + })) + t.Cleanup(srv.Close) + + err = depStorage.StoreConfig(ctx, tokenOrgName, &nanodep_client.Config{BaseURL: srv.URL}) + require.NoError(t, err) + err = depStorage.StoreConfig(ctx, apple_mdm.UnsavedABMTokenOrgName, &nanodep_client.Config{BaseURL: srv.URL}) + require.NoError(t, err) + + logger := log.NewNopLogger() + syncFn := appleMDMDEPSyncerJob(ds, depStorage, logger) + err = syncFn(ctx) + require.NoError(t, err) + + // token has been updated with its org name/apple id + tok, err = ds.GetABMTokenByOrgName(ctx, tokenOrgName) + require.NoError(t, err) + require.Equal(t, tokenOrgName, tok.OrganizationName) + require.Equal(t, "admin123", tok.AppleID) + require.Nil(t, tok.MacOSDefaultTeamID) + require.Nil(t, tok.IOSDefaultTeamID) + require.Nil(t, tok.IPadOSDefaultTeamID) + + // empty-name token does not exist anymore + _, err = ds.GetABMTokenByOrgName(ctx, "") + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + + // the default profile was created + defProf, err := ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic) + require.NoError(t, err) + require.NotNil(t, defProf) + require.NotEmpty(t, defProf.Token) + + // no profile UUID was assigned for no-team (because there are no hosts right now) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "") + require.Error(t, err) + require.ErrorAs(t, err, &nfe) + + // no teams, so no team-specific custom setup assistants + teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}) + require.NoError(t, err) + require.Empty(t, teams) + + // no no-team custom setup assistant + _, err = ds.GetMDMAppleSetupAssistant(ctx, nil) + require.ErrorIs(t, err, sql.ErrNoRows) + + // no host got created + hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}) + require.NoError(t, err) + require.Empty(t, hosts) +} diff --git a/cmd/fleet/main.go b/cmd/fleet/main.go index eff64d3562..b39cbc2ee9 100644 --- a/cmd/fleet/main.go +++ b/cmd/fleet/main.go @@ -2,11 +2,14 @@ package main import ( "fmt" + "io" "math/rand" "os" "time" + "github.com/briandowns/spinner" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/shellquote" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" _ "github.com/go-sql-driver/mysql" @@ -29,6 +32,35 @@ func main() { rootCmd.AddCommand(createConfigDumpCmd(configManager)) rootCmd.AddCommand(createVersionCmd(configManager)) + // See if the program is being piped data on stdin. + fi, err := os.Stdin.Stat() + if err != nil { + initFatal(err, "getting stdin stats") + } + if fi.Mode()&os.ModeNamedPipe != 0 { + _, _ = fmt.Fprintln(os.Stderr, "Reading additional arguments from stdin...") + // See charsets at https://godoc.org/github.com/briandowns/spinner#pkg-variables + s := spinner.New(spinner.CharSets[24], 200*time.Millisecond) + s.Writer = os.Stderr + s.Start() + + data, err := io.ReadAll(os.Stdin) + if err != nil { + initFatal(err, "reading from stdin") + } + + // Split the string into arguments like a shell would. + extraArgs, err := shellquote.Split(string(data)) + if err != nil { + initFatal(err, "splitting arguments from stdin") + } + + // Add the new args to the existing args + os.Args = append(os.Args, extraArgs...) + + s.Stop() + } + if err := rootCmd.Execute(); err != nil { initFatal(err, "running root command") } diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 5c8a3db664..eda0660a73 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -22,7 +22,6 @@ import ( "github.com/e-dard/netbug" "github.com/fleetdm/fleet/v4/ee/server/licensing" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server" configpkg "github.com/fleetdm/fleet/v4/server/config" @@ -50,6 +49,7 @@ import ( "github.com/fleetdm/fleet/v4/server/pubsub" "github.com/fleetdm/fleet/v4/server/service" "github.com/fleetdm/fleet/v4/server/service/async" + "github.com/fleetdm/fleet/v4/server/service/redis_key_value" "github.com/fleetdm/fleet/v4/server/service/redis_lock" "github.com/fleetdm/fleet/v4/server/service/redis_policy_set" "github.com/fleetdm/fleet/v4/server/sso" @@ -75,7 +75,10 @@ import ( var allowedURLPrefixRegexp = regexp.MustCompile("^(?:/[a-zA-Z0-9_.~-]+)+$") -const softwareInstallerUploadTimeout = 4 * time.Minute +const ( + softwareInstallerUploadTimeout = 4 * time.Minute + liveQueryMemCacheDuration = 1 * time.Second +) type initializer interface { // Initialize is used to populate a datastore with @@ -125,6 +128,10 @@ the way that the Fleet server works. logger := initLogger(config) + if dev { + createTestBucketForInstallers(&config, logger) + } + // Init tracing if config.Logging.TracingEnabled { ctx := context.Background() @@ -346,7 +353,7 @@ the way that the Fleet server works. resultStore := pubsub.NewRedisQueryResults(redisPool, config.Redis.DuplicateResults, log.With(logger, "component", "query-results"), ) - liveQueryStore := live_query.NewRedisLiveQuery(redisPool) + liveQueryStore := live_query.NewRedisLiveQuery(redisPool, logger, liveQueryMemCacheDuration) ssoSessionStore := sso.NewSessionStore(redisPool) // Set common configuration for all logging. @@ -493,97 +500,6 @@ the way that the Fleet server works. mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger) } - // validate Apple APNs/SCEP config - if config.MDM.IsAppleAPNsSet() || config.MDM.IsAppleSCEPSet() { - if !config.MDM.IsAppleAPNsSet() { - initFatal(errors.New("Apple APNs MDM configuration must be provided when Apple SCEP is provided"), "validate Apple MDM") - } else if !config.MDM.IsAppleSCEPSet() { - initFatal(errors.New("Apple SCEP MDM configuration must be provided when Apple APNs is provided"), "validate Apple MDM") - } - - if len(config.Server.PrivateKey) == 0 { - initFatal(errors.New("inserting APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - apnsCert, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() - if err != nil { - initFatal(err, "validate Apple APNs certificate and key") - } - - _, appleSCEPCertPEM, appleSCEPKeyPEM, err := config.MDM.AppleSCEP() - if err != nil { - initFatal(err, "validate Apple SCEP certificate and key") - } - - const ( - apnsConnectionTimeout = 10 * time.Second - apnsConnectionURL = "https://api.sandbox.push.apple.com" - ) - - // check that the Apple APNs certificate is valid to connect to the API - ctx, cancel := context.WithTimeout(context.Background(), apnsConnectionTimeout) - if err := certificate.ValidateClientAuthTLSConnection(ctx, apnsCert, apnsConnectionURL); err != nil { - initFatal(err, "validate authentication with Apple APNs certificate") - } - cancel() - - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM}, - {Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM}, - {Name: fleet.MDMAssetCACert, Value: appleSCEPCertPEM}, - {Name: fleet.MDMAssetCAKey, Value: appleSCEPKeyPEM}, - }) - if err != nil { - // duplicate key errors mean that we already - // have a value for those keys in the - // database, fail to initalize on other - // cases. - if !mysql.IsDuplicate(err) { - initFatal(err, "inserting MDM APNs and SCEP assets") - } - - level.Warn(logger).Log("msg", "Your server already has stored SCEP and APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.") - } - } - - // validate Apple BM config - if config.MDM.IsAppleBMSet() { - if !license.IsPremium() { - initFatal(errors.New("Apple Business Manager configuration is only available in Fleet Premium"), "validate Apple BM") - } - - if len(config.Server.PrivateKey) == 0 { - initFatal(errors.New("inserting MDM ABM assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - appleBM, err := config.MDM.AppleBM() - if err != nil { - initFatal(err, "validate Apple BM token, certificate and key") - } - - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM}, - {Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM}, - {Name: fleet.MDMAssetABMToken, Value: appleBM.EncryptedToken}, - }) - if err != nil { - // duplicate key errors mean that we already - // have a value for those keys in the - // database, fail to initalize on other - // cases. - if !mysql.IsDuplicate(err) { - initFatal(err, "inserting MDM ABM assets") - } - - level.Warn(logger).Log("msg", "Your server already has stored ABM certificates and token. Fleet will ignore any certificates provided via environment variables when this happens.") - } - } - - appCfg, err := ds.AppConfig(context.Background()) - if err != nil { - initFatal(err, "loading app config") - } - checkMDMAssets := func(names []fleet.MDMAssetName) (bool, error) { _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), names) if err != nil { @@ -595,6 +511,131 @@ the way that the Fleet server works. return true, nil } + // reconcile Apple Business Manager configuration environment variables with the database + if config.MDM.IsAppleAPNsSet() || config.MDM.IsAppleSCEPSet() { + if len(config.Server.PrivateKey) == 0 { + initFatal(errors.New("inserting MDM APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + // first we'll check if the APNs and SCEP assets are already in the database and + // only insert config values if they're not already present in the database + toInsert := make(map[fleet.MDMAssetName]struct{}, 4) + + // check DB for APNs assets + found, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetAPNSCert, fleet.MDMAssetAPNSKey}) + switch { + case err != nil: + initFatal(err, "reading APNs assets from database") + case !found: + toInsert[fleet.MDMAssetAPNSCert] = struct{}{} + toInsert[fleet.MDMAssetAPNSKey] = struct{}{} + default: + level.Warn(logger).Log("msg", "Your server already has stored APNs certificates. Fleet will ignore any certificates provided via environment variables when this happens.") + } + + // check DB for SCEP assets + found, err = checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey}) + switch { + case err != nil: + initFatal(err, "reading SCEP assets from database") + case !found: + toInsert[fleet.MDMAssetCACert] = struct{}{} + toInsert[fleet.MDMAssetCAKey] = struct{}{} + default: + level.Warn(logger).Log("msg", "Your server already has stored SCEP certificates. Fleet will ignore any certificates provided via environment variables when this happens.") + } + + if len(toInsert) > 0 { + if !config.MDM.IsAppleAPNsSet() { + initFatal(errors.New("Apple APNs MDM configuration must be provided when Apple SCEP is provided"), "validate Apple MDM") + } else if !config.MDM.IsAppleSCEPSet() { + initFatal(errors.New("Apple SCEP MDM configuration must be provided when Apple APNs is provided"), "validate Apple MDM") + } + + // parse the APNs and SCEP assets from the config + _, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() + if err != nil { + initFatal(err, "parse Apple APNs certificate and key from config") + } + _, appleSCEPCertPEM, appleSCEPKeyPEM, err := config.MDM.AppleSCEP() + if err != nil { + initFatal(err, "load Apple SCEP certificate and key from config") + } + + var args []fleet.MDMConfigAsset + for name := range toInsert { + switch name { + case fleet.MDMAssetAPNSCert: + args = append(args, fleet.MDMConfigAsset{Name: name, Value: apnsCertPEM}) + case fleet.MDMAssetAPNSKey: + args = append(args, fleet.MDMConfigAsset{Name: name, Value: apnsKeyPEM}) + case fleet.MDMAssetCACert: + args = append(args, fleet.MDMConfigAsset{Name: name, Value: appleSCEPCertPEM}) + case fleet.MDMAssetCAKey: + args = append(args, fleet.MDMConfigAsset{Name: name, Value: appleSCEPKeyPEM}) + } + } + + if err := ds.InsertMDMConfigAssets(context.Background(), args); err != nil { + if mysql.IsDuplicate(err) { + // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case + level.Debug(logger).Log("msg", "unexpected duplicate key error inserting MDM APNs and SCEP assets") + } else { + initFatal(err, "inserting MDM APNs and SCEP assets") + } + } + } + } + + // reconcile Apple Business Manager configuration environment variables with the database + if config.MDM.IsAppleBMSet() { + if len(config.Server.PrivateKey) == 0 { + initFatal(errors.New("inserting MDM ABM assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + appleBM, err := config.MDM.AppleBM() + if err != nil { + initFatal(err, "parse Apple BM token, certificate and key from config") + } + + toInsert := make([]fleet.MDMConfigAsset, 0, 2) + + found, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetABMKey, fleet.MDMAssetABMCert}) + switch { + case err != nil: + initFatal(err, "reading ABM assets from database") + case !found: + toInsert = append(toInsert, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMKey, Value: appleBM.KeyPEM}, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMCert, Value: appleBM.CertPEM}) + default: + level.Warn(logger).Log("msg", "Your server already has stored ABM certificates and token. Fleet will ignore any certificates provided via environment variables when this happens.") + } + + if len(toInsert) > 0 { + err := ds.InsertMDMConfigAssets(context.Background(), toInsert) + switch { + case err != nil && mysql.IsDuplicate(err): + // we already checked for existing assets so we should never have a duplicate key error here; we'll add a debug log just in case + level.Debug(logger).Log("msg", "unexpected duplicate key error inserting ABM assets") + case err != nil: + initFatal(err, "inserting ABM assets") + default: + // insert the ABM token without any metdata; it'll be picked by the + // apple_mdm_dep_profile_assigner cron and backfilled + if _, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{ + EncryptedToken: appleBM.EncryptedToken, + RenewAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), // 2000-01-01 is our "zero value" for time + }); err != nil { + initFatal(err, "save ABM token") + } + } + } + } + + appCfg, err := ds.AppConfig(context.Background()) + if err != nil { + initFatal(err, "loading app config") + } + appCfg.MDM.EnabledAndConfigured = false appCfg.MDM.AppleBMEnabledAndConfigured = false if len(config.Server.PrivateKey) > 0 { @@ -605,17 +646,32 @@ the way that the Fleet server works. fleet.MDMAssetAPNSCert, }) if err != nil { - initFatal(err, "validating MDM assets from database") + initFatal(err, "loading MDM assets from database") } - appCfg.MDM.AppleBMEnabledAndConfigured, err = checkMDMAssets([]fleet.MDMAssetName{ + var appleBMCerts bool + appleBMCerts, err = checkMDMAssets([]fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, }) if err != nil { - initFatal(err, "validating MDM ABM assets from database") + initFatal(err, "loading MDM ABM assets from database") } + if appleBMCerts { + // the ABM certs are there, check if a token exists and if so, apple + // BM is enabled and configured. + count, err := ds.GetABMTokenCount(context.Background()) + if err != nil { + initFatal(err, "loading MDM ABM token from database") + } + appCfg.MDM.AppleBMEnabledAndConfigured = count > 0 + } + } + if appCfg.MDM.EnabledAndConfigured { + level.Info(logger).Log("msg", "Apple MDM enabled") + } + if appCfg.MDM.AppleBMEnabledAndConfigured { + level.Info(logger).Log("msg", "Apple Business Manager enabled") } // register the Microsoft MDM services @@ -692,6 +748,7 @@ the way that the Fleet server works. } var softwareInstallStore fleet.SoftwareInstallerStore + var bootstrapPackageStore fleet.MDMBootstrapPackageStore var distributedLock fleet.Lock if license.IsPremium() { profileMatcher := apple_mdm.NewProfileMatcher(redisPool) @@ -705,6 +762,13 @@ the way that the Fleet server works. } softwareInstallStore = store level.Info(logger).Log("msg", "using S3 software installer store", "bucket", config.S3.SoftwareInstallersBucket) + + bstore, err := s3.NewBootstrapPackageStore(config.S3) + if err != nil { + initFatal(err, "initializing S3 bootstrap package store") + } + bootstrapPackageStore = bstore + level.Info(logger).Log("msg", "using S3 bootstrap package store", "bucket", config.S3.SoftwareInstallersBucket) } else { installerDir := os.TempDir() if dir := os.Getenv("FLEET_SOFTWARE_INSTALLER_STORE_DIR"); dir != "" { @@ -733,7 +797,9 @@ the way that the Fleet server works. ssoSessionStore, profileMatcher, softwareInstallStore, + bootstrapPackageStore, distributedLock, + redis_key_value.New(redisPool), ) if err != nil { initFatal(err, "initial Fleet Premium service") @@ -773,6 +839,16 @@ the way that the Fleet server works. } }() + if softwareInstallStore != nil { + if err := cronSchedules.StartCronSchedule( + func() (fleet.CronSchedule, error) { + return cronUninstallSoftwareMigration(ctx, instanceID, ds, softwareInstallStore, logger) + }, + ); err != nil { + initFatal(err, fmt.Sprintf("failed to register %s", fleet.CronUninstallSoftwareMigration)) + } + } + if config.Server.FrequentCleanupsEnabled { if err := cronSchedules.StartCronSchedule( func() (fleet.CronSchedule, error) { @@ -787,7 +863,7 @@ the way that the Fleet server works. func() (fleet.CronSchedule, error) { commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) return newCleanupsAndAggregationSchedule( - ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore, + ctx, instanceID, ds, logger, redisWrapperDS, &config, commander, softwareInstallStore, bootstrapPackageStore, ) }, ); err != nil { @@ -936,7 +1012,7 @@ the way that the Fleet server works. KeyPrefix: "ratelimit::", } - var apiHandler, frontendHandler http.Handler + var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler { frontendHandler = service.PrometheusMetricsHandler( "get_frontend", @@ -954,8 +1030,10 @@ the way that the Fleet server works. if setupRequired { apiHandler = service.WithSetup(svc, logger, apiHandler) frontendHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) } else { frontendHandler = service.RedirectSetupToLogin(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.ServeEndUserEnrollOTA(config.Server.URLPrefix, logger) } } @@ -1090,6 +1168,7 @@ the way that the Fleet server works. } apiHandler.ServeHTTP(rw, req) }) + rootMux.Handle("/enroll", endUserEnrollOTAHandler) rootMux.Handle("/", frontendHandler) debugHandler := &debugMux{ @@ -1131,7 +1210,11 @@ the way that the Fleet server works. rootMux = prefixMux } - liveQueryRestPeriod := 90 * time.Second // default (see #1798) + // NOTE(lucas): It seems we missed updating this value from 90s (see #1798) to 25s after we + // decided to make the synchronous live query API to take up to 25 seconds. + // Not changing this to not break any long running requests (like when uploading software + // packages via GitOps). + liveQueryRestPeriod := 90 * time.Second if v := os.Getenv("FLEET_LIVE_QUERY_REST_PERIOD"); v != "" { duration, err := time.ParseDuration(v) if err != nil { @@ -1374,3 +1457,18 @@ var _ push.Pusher = nopPusher{} func (n nopPusher) Push(context.Context, []string) (map[string]*push.Response, error) { return nil, nil } + +func createTestBucketForInstallers(config *configpkg.FleetConfig, logger log.Logger) { + store, err := s3.NewSoftwareInstallerStore(config.S3) + if err != nil { + initFatal(err, "initializing S3 software installer store") + } + if err := store.CreateTestBucket(config.S3.SoftwareInstallersBucket); err != nil { + // Don't panic, allow devs to run Fleet without minio/S3 dependency. + level.Info(logger).Log( + "err", err, + "msg", "failed to create test bucket", + "name", config.S3.SoftwareInstallersBucket, + ) + } +} diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 41e7d3885a..e472566f3e 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -30,9 +30,9 @@ import ( "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // safeStore is a wrapper around mock.Store to allow for concurrent calling to @@ -116,6 +116,10 @@ func TestMaybeSendStatistics(t *testing.T) { HostsEnrolledByOsqueryVersion: []fleet.HostsCountByOsqueryVersion{}, StoredErrors: []byte(`[]`), Organization: "Fleet", + AIFeaturesDisabled: true, + MaintenanceWindowsEnabled: true, + MaintenanceWindowsConfigured: true, + NumHostsFleetDesktopEnabled: 1984, }, true, nil } recorded := false @@ -134,7 +138,7 @@ func TestMaybeSendStatistics(t *testing.T) { require.NoError(t, err) assert.True(t, recorded) require.True(t, cleanedup) - assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0}`, requestBody) + assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984}`, requestBody) } func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) { @@ -294,6 +298,7 @@ func TestAutomationsSchedule(t *testing.T) { } func TestCronVulnerabilitiesCreatesDatabasesPath(t *testing.T) { + t.Parallel() ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index 751c74709f..b201f5dc94 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -90,7 +90,7 @@ func applyCommand() *cli.Command { opts.TeamForPolicies = policiesTeamName } baseDir := filepath.Dir(flFilename) - _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, opts) + _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) if err != nil { return err } diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 3cd8eb03dd..f35b39dc84 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -23,9 +23,11 @@ import ( "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" "github.com/fleetdm/fleet/v4/server/mock" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" + nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service" "github.com/google/uuid" @@ -167,12 +169,15 @@ func TestApplyTeamSpecs(t *testing.T) { return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( @@ -586,6 +591,10 @@ func TestApplyAppConfig(t *testing.T) { return userRoleSpecList, nil } + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { @@ -623,8 +632,9 @@ func TestApplyAppConfig(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { @@ -640,6 +650,12 @@ func TestApplyAppConfig(t *testing.T) { ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, nil + } + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + return []*fleet.TeamSummary{{Name: "team1", ID: 1}}, nil + } name := writeTmpYml(t, `--- apiVersion: v1 @@ -659,8 +675,8 @@ spec: `) newMDMSettings := fleet.MDM{ - AppleBMDefaultTeam: "team1", - AppleBMTermsExpired: false, + DeprecatedAppleBMDefaultTeam: "team1", + AppleBMTermsExpired: false, MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), @@ -714,8 +730,8 @@ spec: `) newMDMSettings = fleet.MDM{ - AppleBMDefaultTeam: "team1", - AppleBMTermsExpired: false, + DeprecatedAppleBMDefaultTeam: "team1", + AppleBMTermsExpired: false, MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), @@ -1157,6 +1173,9 @@ func TestApplyAsGitOps(t *testing.T) { testCertPEM := tokenpki.PEMCertificate(testCert.Raw) testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey) fleetCfg := config.TestConfig() + // Mock Apple DEP API + depStorage := SetupMockDEPStorageAndMockDEPServer(t) + config.SetTestMDMConfig(t, &fleetCfg, testCertPEM, testKeyPEM, "../../server/service/testdata") _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ @@ -1164,6 +1183,7 @@ func TestApplyAsGitOps(t *testing.T) { MDMStorage: enqueuer, MDMPusher: mockPusher{}, FleetConfig: &fleetCfg, + DEPStorage: depStorage, }) gitOps := &fleet.User{ @@ -1240,11 +1260,14 @@ func TestApplyAsGitOps(t *testing.T) { teamEnrollSecrets = secrets return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { return nil, ¬FoundError{} @@ -1258,7 +1281,7 @@ func TestApplyAsGitOps(t *testing.T) { ds.GetMDMAppleBootstrapPackageMetaFunc = func(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackage, error) { return nil, ¬FoundError{} } - ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { + ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { return nil } ds.SetOrUpdateMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { @@ -1279,6 +1302,20 @@ func TestApplyAsGitOps(t *testing.T) { return nil } + ds.GetMDMAppleEnrollmentProfileByTypeFunc = func(ctx context.Context, typ fleet.MDMAppleEnrollmentType) (*fleet.MDMAppleEnrollmentProfile, error) { + return &fleet.MDMAppleEnrollmentProfile{Token: "foobar"}, nil + } + ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { + return 0, nil + } + + ds.GetABMTokenOrgNamesAssociatedWithTeamFunc = func(ctx context.Context, teamID *uint) ([]string, error) { + return []string{"foobar"}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + // Apply global config. name := writeTmpYml(t, `--- apiVersion: v1 @@ -1615,6 +1652,34 @@ spec: assert.Equal(t, "select * from app_schemes;", appliedQueries[0].Query) } +func SetupMockDEPStorageAndMockDEPServer(t *testing.T) *nanodep_mock.Storage { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/server/devices"): + _, err := w.Write([]byte("{}")) + require.NoError(t, err) + case strings.Contains(r.URL.Path, "/session"): + _, err := w.Write([]byte(`{"auth_session_token": "yoo"}`)) + require.NoError(t, err) + case strings.Contains(r.URL.Path, "/profile"): + _, err := w.Write([]byte(`{"profile_uuid": "profile123"}`)) + require.NoError(t, err) + } + })) + depStorage := &nanodep_mock.Storage{} + depStorage.RetrieveConfigFunc = func(context.Context, string) (*nanodep_client.Config, error) { + return &nanodep_client.Config{ + BaseURL: ts.URL, + }, nil + } + depStorage.RetrieveAuthTokensFunc = func(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) { + return &nanodep_client.OAuth1Tokens{}, nil + } + t.Cleanup(func() { ts.Close() }) + + return depStorage +} + func TestApplyEnrollSecrets(t *testing.T) { _, ds := runServerWithMockedDS(t) @@ -1868,7 +1933,8 @@ func TestApplyMacosSetup(t *testing.T) { tier = fleet.TierPremium } license := &fleet.LicenseInfo{Tier: tier, Expiration: time.Now().Add(24 * time.Hour)} - _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: license}) + depStorage := SetupMockDEPStorageAndMockDEPServer(t) + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: license, DEPStorage: depStorage}) tm1 := &fleet.Team{ID: 1, Name: "tm1"} teamsByName := map[string]*fleet.Team{ @@ -2001,7 +2067,7 @@ func TestApplyMacosSetup(t *testing.T) { } return nil, ¬FoundError{} } - ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { + ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { return nil } ds.DeleteMDMAppleBootstrapPackageFunc = func(ctx context.Context, teamID uint) error { @@ -2010,6 +2076,21 @@ func TestApplyMacosSetup(t *testing.T) { ds.GetMDMAppleBootstrapPackageMetaFunc = func(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackage, error) { return nil, nil } + + ds.GetMDMAppleEnrollmentProfileByTypeFunc = func(ctx context.Context, typ fleet.MDMAppleEnrollmentType) (*fleet.MDMAppleEnrollmentProfile, error) { + return &fleet.MDMAppleEnrollmentProfile{Token: "foobar"}, nil + } + ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { + return 0, nil + } + + ds.GetABMTokenOrgNamesAssociatedWithTeamFunc = func(ctx context.Context, teamID *uint) ([]string, error) { + return []string{"foobar"}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + return ds } @@ -2387,7 +2468,7 @@ spec: t.Run(c.pkgName, func(t *testing.T) { srv, pkgLen := serveMDMBootstrapPackage(t, filepath.Join("../../server/service/testdata/bootstrap-packages", c.pkgName), c.pkgName) ds := setupServer(t, true) - ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { + ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { require.Equal(t, len(bp.Bytes), pkgLen) return nil } @@ -2446,7 +2527,7 @@ spec: defer srv.Close() ds := setupServer(t, true) - ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { + ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { mockStore.Lock() defer mockStore.Unlock() require.Equal(t, pkgName, bp.Name) @@ -2599,6 +2680,7 @@ spec: } func TestApplySpecs(t *testing.T) { + t.Parallel() // create a macos setup json file (content not important) macSetupFile := writeTmpJSON(t, map[string]any{}) diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index db5a00c9b2..f39ff1cd55 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2060,8 +2060,13 @@ func TestGetAppleBM(t *testing.T) { assert.Contains(t, err.Error(), expected) }) - t.Run("premium license", func(t *testing.T) { - runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + t.Run("premium license, single token", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{ + {ID: 1}, + }, nil + } out := runAppForTest(t, []string{"get", "mdm_apple_bm"}) assert.Contains(t, out, "Apple ID:") @@ -2070,6 +2075,29 @@ func TestGetAppleBM(t *testing.T) { assert.Contains(t, out, "Renew date:") assert.Contains(t, out, "Default team:") }) + + t.Run("premium license, no token", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return nil, nil + } + + out := runAppForTest(t, []string{"get", "mdm_apple_bm"}) + assert.Contains(t, out, "No Apple Business Manager server token found.") + }) + + t.Run("premium license, multiple tokens", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{ + {ID: 1}, + {ID: 2}, + }, nil + } + + _, err := runAppNoChecks([]string{"get", "mdm_apple_bm"}) + assert.ErrorContains(t, err, "This API endpoint has been deprecated. Please use the new GET /abm_tokens API endpoint") + }) } func TestGetCarves(t *testing.T) { @@ -2269,11 +2297,14 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { } return nil, fmt.Errorf("team not found: %s", name) } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil diff --git a/cmd/fleetctl/gitops.go b/cmd/fleetctl/gitops.go index c56016eca3..a7db6be73d 100644 --- a/cmd/fleetctl/gitops.go +++ b/cmd/fleetctl/gitops.go @@ -77,12 +77,35 @@ func gitopsCommand() *cli.Command { if appConfig.License == nil { return errors.New("no license struct found in app config") } + logf := func(format string, a ...interface{}) { + _, _ = fmt.Fprintf(c.App.Writer, format, a...) + } - var appleBMDefaultTeam string - var appleBMDefaultTeamFound bool + // We need to extract the controls from no-team.yml to be able to apply them when applying the global app config. + var ( + noTeamControls spec.Controls + noTeamPresent bool + ) + for _, flFilename := range flFilenames.Value() { + if filepath.Base(flFilename) == "no-team.yml" { + baseDir := filepath.Dir(flFilename) + config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, func(format string, a ...interface{}) {}) + if err != nil { + return err + } + noTeamControls = config.Controls + noTeamPresent = true + break + } + } + + var originalABMConfig []any + var originalVPPConfig []any var teamNames []string var firstFileMustBeGlobal *bool var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions + var abmTeams, vppTeams []string + var hasMissingABMTeam, hasMissingVPPTeam, usesLegacyABMConfig bool if totalFilenames > 1 { firstFileMustBeGlobal = ptr.Bool(true) } @@ -90,7 +113,7 @@ func gitopsCommand() *cli.Command { secrets := make(map[string]struct{}) for _, flFilename := range flFilenames.Value() { baseDir := filepath.Dir(flFilename) - config, err := spec.GitOpsFromFile(flFilename, baseDir) + config, err := spec.GitOpsFromFile(flFilename, baseDir, appConfig, logf) if err != nil { return err } @@ -106,16 +129,74 @@ func gitopsCommand() *cli.Command { } firstFileMustBeGlobal = ptr.Bool(false) } - if isGlobalConfig && totalFilenames > 1 { - // Check if Apple BM default team already exists - appleBMDefaultTeam, appleBMDefaultTeamFound, err = checkAppleBMDefaultTeam(config, fleetClient) + + if isGlobalConfig { + if noTeamControls.Set() && config.Controls.Set() { + return errors.New("'controls' cannot be set on both global config and on no-team.yml") + } + if !noTeamControls.Defined && !config.Controls.Defined { + if appConfig.License.IsPremium() { + return errors.New("'controls' must be set on global config or no-team.yml") + } + return errors.New("'controls' must be set on global config") + } + if !config.Controls.Set() { + config.Controls = noTeamControls + } + } + + // Special handling for tokens is required because they link to teams (by + // name.) Because teams can be created/deleted during the same gitops run, we + // grab some information to help us determine allowed/restricted actions and + // when to perform the associations. + if isGlobalConfig && totalFilenames > 1 && !(totalFilenames == 2 && noTeamPresent) { + abmTeams, hasMissingABMTeam, usesLegacyABMConfig, err = checkABMTeamAssignments(config, fleetClient) if err != nil { return err } + + vppTeams, hasMissingVPPTeam, err = checkVPPTeamAssignments(config, fleetClient) + if err != nil { + return err + } + + // if one of the teams assigned to an ABM token doesn't exist yet, we need to + // submit the configs without the ABM default team set. We'll set those + // separately later when the teams are already created. + if hasMissingABMTeam { + if mdm, ok := config.OrgSettings["mdm"]; ok { + if mdmMap, ok := mdm.(map[string]any); ok { + if appleBM, ok := mdmMap["apple_business_manager"]; ok { + if bmSettings, ok := appleBM.([]any); ok { + originalABMConfig = bmSettings + } + } + + // If team is not found, we need to remove the AppleBMDefaultTeam from + // the global config, and then apply it after teams are processed + mdmMap["apple_business_manager"] = nil + mdmMap["apple_bm_default_team"] = "" + } + } + } + + if hasMissingVPPTeam { + if mdm, ok := config.OrgSettings["mdm"]; ok { + if mdmMap, ok := mdm.(map[string]any); ok { + if vpp, ok := mdmMap["volume_purchasing_program"]; ok { + if vppSettings, ok := vpp.([]any); ok { + originalVPPConfig = vppSettings + } + } + + // If team is not found, we need to remove the VPP config from + // the global config, and then apply it after teams are processed + mdmMap["volume_purchasing_program"] = nil + } + } + } } - logf := func(format string, a ...interface{}) { - _, _ = fmt.Fprintf(c.App.Writer, format, a...) - } + if flDryRun { incomingSecrets := fleetClient.GetGitOpsSecrets(config) for _, secret := range incomingSecrets { @@ -125,6 +206,7 @@ func gitopsCommand() *cli.Command { secrets[secret] = struct{}{} } } + assumptions, err := fleetClient.DoGitOps(c.Context, config, flFilename, logf, flDryRun, teamDryRunAssumptions, appConfig) if err != nil { return err @@ -135,10 +217,15 @@ func gitopsCommand() *cli.Command { teamDryRunAssumptions = assumptions } } - if appleBMDefaultTeam != "" && !appleBMDefaultTeamFound { - // If the Apple BM default team did not exist earlier, check again and apply it if needed - err = applyAppleBMDefaultTeamIfNeeded(c, teamNames, appleBMDefaultTeam, flDryRun, fleetClient) - if err != nil { + + // if there were assignments to tokens, and some of the teams were missing at that time, submit a separate patch request to set them now. + if len(abmTeams) > 0 && hasMissingABMTeam { + if err = applyABMTokenAssignmentIfNeeded(c, teamNames, abmTeams, originalABMConfig, usesLegacyABMConfig, flDryRun, fleetClient); err != nil { + return err + } + } + if len(vppTeams) > 0 && hasMissingVPPTeam { + if err = applyVPPTokenAssignmentIfNeeded(c, teamNames, vppTeams, originalVPPConfig, flDryRun, fleetClient); err != nil { return err } } @@ -149,8 +236,14 @@ func gitopsCommand() *cli.Command { } for _, team := range teams { if !slices.Contains(teamNames, team.Name) { - if appleBMDefaultTeam == team.Name { - return fmt.Errorf("apple_bm_default_team %s cannot be deleted", appleBMDefaultTeam) + if slices.Contains(abmTeams, team.Name) { + if usesLegacyABMConfig { + return fmt.Errorf("apple_bm_default_team %s cannot be deleted", team.Name) + } + return fmt.Errorf("apple_business_manager team %s cannot be deleted", team.Name) + } + if slices.Contains(vppTeams, team.Name) { + return fmt.Errorf("volume_purchasing_program team %s cannot be deleted", team.Name) } if flDryRun { _, _ = fmt.Fprintf(c.App.Writer, "[!] would delete team %s\n", team.Name) @@ -174,54 +267,194 @@ func gitopsCommand() *cli.Command { } } -func checkAppleBMDefaultTeam(config *spec.GitOps, fleetClient *service.Client) ( - appleBMDefaultTeam string, appleBMDefaultTeamFound bool, err error, +// checkABMTeamAssignments validates the spec, and finds if: +// +// 1. The user is using the legacy apple_bm_default_team config. +// 2. All teams assigned to ABM tokens already exist. +// 3. Performs validations according to the spec for both the new and the +// deprecated key used for this setting. +func checkABMTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( + abmTeams []string, missingTeam bool, usesLegacyConfig bool, err error, ) { if mdm, ok := config.OrgSettings["mdm"]; ok { - if mdmMap, ok := mdm.(map[string]interface{}); ok { - if appleBMDT, ok := mdmMap["apple_bm_default_team"]; ok { - if appleBMDefaultTeam, ok = appleBMDT.(string); ok { - teams, err := fleetClient.ListTeams("") - if err != nil { - return "", false, err - } - // Normalize AppleBMDefaultTeam for Unicode support + if mdmMap, ok := mdm.(map[string]any); ok { + appleBMDT, hasLegacyConfig := mdmMap["apple_bm_default_team"] + appleBM, hasNewConfig := mdmMap["apple_business_manager"] + + if hasLegacyConfig && hasNewConfig { + return nil, false, false, errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage) + } + + if !hasLegacyConfig && !hasNewConfig { + return nil, false, false, nil + } + + teams, err := fleetClient.ListTeams("") + if err != nil { + return nil, false, false, err + } + teamNames := map[string]struct{}{} + for _, tm := range teams { + teamNames[tm.Name] = struct{}{} + } + + if hasLegacyConfig { + if appleBMDefaultTeam, ok := appleBMDT.(string); ok { + // normalize for Unicode support appleBMDefaultTeam = norm.NFC.String(appleBMDefaultTeam) - for _, team := range teams { - if team.Name == appleBMDefaultTeam { - appleBMDefaultTeamFound = true - break - } + abmTeams = append(abmTeams, appleBMDefaultTeam) + usesLegacyConfig = true + if _, ok = teamNames[appleBMDefaultTeam]; !ok { + missingTeam = true } - if !appleBMDefaultTeamFound { - // If team is not found, we need to remove the AppleBMDefaultTeam from the global config, and then apply it after teams are processed - mdmMap["apple_bm_default_team"] = "" + } + } + + if hasNewConfig { + if settingMap, ok := appleBM.([]any); ok { + for _, item := range settingMap { + if cfg, ok := item.(map[string]any); ok { + for _, teamConfigKey := range []string{"macos_team", "ios_team", "ipados_team"} { + if team, ok := cfg[teamConfigKey].(string); ok && team != "" { + // normalize for Unicode support + team = norm.NFC.String(team) + abmTeams = append(abmTeams, team) + if _, ok := teamNames[team]; !ok { + missingTeam = true + } + } + } + } } } } } } - return appleBMDefaultTeam, appleBMDefaultTeamFound, nil + + return abmTeams, missingTeam, usesLegacyConfig, nil } -func applyAppleBMDefaultTeamIfNeeded( - ctx *cli.Context, teamNames []string, appleBMDefaultTeam string, flDryRun bool, fleetClient *service.Client, +func applyABMTokenAssignmentIfNeeded( + ctx *cli.Context, + teamNames []string, + abmTeamNames []string, + originalMDMConfig []any, + usesLegacyConfig bool, + flDryRun bool, + fleetClient *service.Client, ) error { - if !slices.Contains(teamNames, appleBMDefaultTeam) { - return fmt.Errorf("apple_bm_default_team %s not found in team configs", appleBMDefaultTeam) + if usesLegacyConfig && len(abmTeamNames) > 1 { + return errors.New(fleet.AppleABMDefaultTeamDeprecatedMessage) } - appConfigUpdate := map[string]map[string]interface{}{ - "mdm": { - "apple_bm_default_team": appleBMDefaultTeam, - }, + + if usesLegacyConfig && len(abmTeamNames) == 0 { + return errors.New("using legacy config without any ABM teams defined") } - if flDryRun { - _, _ = fmt.Fprintf(ctx.App.Writer, "[!] would apply apple_bm_default_team %s\n", appleBMDefaultTeam) - } else { - _, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying apple_bm_default_team %s\n", appleBMDefaultTeam) - if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil { - return fmt.Errorf("applying fleet config: %w", err) + + var appConfigUpdate map[string]map[string]any + if usesLegacyConfig { + appleBMDefaultTeam := abmTeamNames[0] + if !slices.Contains(teamNames, appleBMDefaultTeam) { + return fmt.Errorf("apple_bm_default_team team %q not found in team configs", appleBMDefaultTeam) } + appConfigUpdate = map[string]map[string]any{ + "mdm": { + "apple_bm_default_team": appleBMDefaultTeam, + }, + } + } else { + for _, abmTeam := range abmTeamNames { + if !slices.Contains(teamNames, abmTeam) { + return fmt.Errorf("apple_business_manager team %q not found in team configs", abmTeam) + } + } + + appConfigUpdate = map[string]map[string]any{ + "mdm": { + "apple_business_manager": originalMDMConfig, + }, + } + } + + if flDryRun { + _, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply ABM teams\n") + return nil + } + _, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying ABM teams\n") + if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil { + return fmt.Errorf("applying fleet config: %w", err) + } + return nil +} + +func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) ( + vppTeams []string, missingTeam bool, err error, +) { + if mdm, ok := config.OrgSettings["mdm"]; ok { + if mdmMap, ok := mdm.(map[string]any); ok { + teams, err := fleetClient.ListTeams("") + if err != nil { + return nil, false, err + } + teamNames := map[string]struct{}{} + for _, tm := range teams { + teamNames[tm.Name] = struct{}{} + } + + if vpp, ok := mdmMap["volume_purchasing_program"]; ok { + if vppInterfaces, ok := vpp.([]any); ok { + for _, item := range vppInterfaces { + if itemMap, ok := item.(map[string]any); ok { + if teams, ok := itemMap["teams"].([]any); ok { + for _, team := range teams { + if teamStr, ok := team.(string); ok { + // normalize for Unicode support + normalizedTeam := norm.NFC.String(teamStr) + vppTeams = append(vppTeams, normalizedTeam) + if _, ok := teamNames[normalizedTeam]; !ok { + missingTeam = true + } + } + } + } + } + } + } + } + } + } + + return vppTeams, missingTeam, nil +} + +func applyVPPTokenAssignmentIfNeeded( + ctx *cli.Context, + teamNames []string, + vppTeamNames []string, + originalVPPConfig []any, + flDryRun bool, + fleetClient *service.Client, +) error { + var appConfigUpdate map[string]map[string]any + for _, vppTeam := range vppTeamNames { + if !fleet.IsReservedTeamName(vppTeam) && !slices.Contains(teamNames, vppTeam) { + return fmt.Errorf("volume_purchasing_program team %s not found in team configs", vppTeam) + } + } + + appConfigUpdate = map[string]map[string]any{ + "mdm": { + "volume_purchasing_program": originalVPPConfig, + }, + } + + if flDryRun { + _, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply volume_purchasing_program teams\n") + return nil + } + _, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying volume_purchasing_program teams\n") + if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil { + return fmt.Errorf("applying fleet config for volume_purchasing_program teams: %w", err) } return nil } diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index ab3f8bf835..235d89dca8 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -23,7 +23,7 @@ import ( "github.com/stretchr/testify/suite" ) -func TestEnterpriseIntegrationsGitops(t *testing.T) { +func TestIntegrationsEnterpriseGitops(t *testing.T) { testingSuite := new(enterpriseIntegrationGitopsTestSuite) testingSuite.suite = &testingSuite.Suite suite.Run(t, testingSuite) @@ -176,6 +176,7 @@ contexts: fmt.Sprintf( ` controls: +software: queries: policies: agent_options: @@ -186,6 +187,9 @@ team_settings: ), ) require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, s.ds) + // Apply the team to be deleted _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()}) @@ -230,5 +234,4 @@ team_settings: for _, fileName := range teamFileNames { _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", fileName}) } - } diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 826dd43374..1a054482f6 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -12,9 +10,11 @@ import ( "path/filepath" "slices" "strings" + "sync" "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -25,6 +25,7 @@ import ( mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service" + "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -36,13 +37,13 @@ const ( orgName = "GitOps Test" ) -func TestFilenameValidation(t *testing.T) { +func TestGitOpsFilenameValidation(t *testing.T) { filename := strings.Repeat("a", filenameMaxLength+1) _, err := runAppNoChecks([]string{"gitops", "-f", filename}) assert.ErrorContains(t, err, "file name must be less than") } -func TestBasicGlobalFreeGitOps(t *testing.T) { +func TestGitOpsBasicGlobalFree(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables _, ds := runServerWithMockedDS(t) @@ -50,13 +51,13 @@ func TestBasicGlobalFreeGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -142,6 +143,28 @@ org_settings: require.Error(t, err) assert.Contains(t, err.Error(), "organization name must be present") + // Missing controls. + tmpFile2, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile2.WriteString( + ` +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: https://example.com + org_info: + contact_url: https://example.com/contact + org_name: Foobar + secrets: +`, + ) + require.NoError(t, err) + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile2.Name()}) + require.Error(t, err) + assert.Equal(t, `'controls' must be set on global config`, err.Error()) + // Dry run t.Setenv("ORG_NAME", orgName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) @@ -154,26 +177,27 @@ org_settings: assert.Empty(t, enrolledSecrets) } -func TestBasicGlobalPremiumGitOps(t *testing.T) { +func TestGitOpsBasicGlobalPremium(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ - License: license, + License: license, + KeyValueStore: newMemKeyValueStore(), }, ) ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -207,6 +231,12 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -238,6 +268,7 @@ org_settings: org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: +software: `, ) require.NoError(t, err) @@ -254,18 +285,19 @@ org_settings: assert.Empty(t, enrolledSecrets) } -func TestBasicTeamGitOps(t *testing.T) { +func TestGitOpsBasicTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ - License: license, + License: license, + KeyValueStore: newMemKeyValueStore(), }, ) const secret = "TestSecret" - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { @@ -274,13 +306,13 @@ func TestBasicTeamGitOps(t *testing.T) { ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, @@ -350,6 +382,9 @@ func TestBasicTeamGitOps(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledTeamSecrets = secrets return nil @@ -360,6 +395,9 @@ func TestBasicTeamGitOps(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -381,6 +419,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: ${TEST_SECRET} +software: `, ) require.NoError(t, err) @@ -391,6 +430,26 @@ team_settings: require.Error(t, err) assert.Contains(t, err.Error(), "'name' is required") + // Invalid name for "No team" file (dry and real). + t.Setenv("TEST_TEAM_NAME", "no TEam") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name())) + t.Setenv("TEST_TEAM_NAME", "no TEam") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", tmpFile.Name())) + + t.Setenv("TEST_TEAM_NAME", "All teams") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"All teams" is a reserved team name`) + + t.Setenv("TEST_TEAM_NAME", "All TEAMS") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"All teams" is a reserved team name`) + // Dry run t.Setenv("TEST_TEAM_NAME", teamName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) @@ -413,7 +472,7 @@ team_settings: assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) } -func TestFullGlobalGitOps(t *testing.T) { +func TestGitOpsFullGlobal(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. testCert, testKey, err := apple_mdm.NewSCEPCACertKey() @@ -446,13 +505,14 @@ func TestFullGlobalGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -537,16 +597,10 @@ func TestFullGlobalGitOps(t *testing.T) { ) t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) - t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) + t.Setenv("SOFTWARE_INSTALLER_URL", fleetServerURL) file := "./testdata/gitops/global_config_no_paths.yml" - // Dry run should fail because Apple BM Default Team does not exist and premium license is not set - _, err = runAppNoChecks([]string{"gitops", "-f", file, "--dry-run"}) - require.Error(t, err) - assert.True(t, strings.Contains(err.Error(), "missing or invalid license")) - // Dry run - t.Setenv("APPLE_BM_DEFAULT_TEAM", "") _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") assert.Len(t, enrolledSecrets, 0) @@ -579,7 +633,7 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Equal(t, "https://activities_webhook_url", savedAppConfig.WebhookSettings.ActivitiesWebhook.DestinationURL) } -func TestFullTeamGitOps(t *testing.T) { +func TestGitOpsFullTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} @@ -599,6 +653,7 @@ func TestFullTeamGitOps(t *testing.T) { MDMPusher: mockPusher{}, FleetConfig: &fleetCfg, NoCacheDatastore: true, + KeyValueStore: newMemKeyValueStore(), }, ) @@ -627,13 +682,14 @@ func TestFullTeamGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -659,6 +715,9 @@ func TestFullTeamGitOps(t *testing.T) { // Team var savedTeam *fleet.Team ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == "Conflict" { + return &fleet.Team{}, nil + } if savedTeam != nil && savedTeam.Name == name { return savedTeam, nil } @@ -754,10 +813,15 @@ func TestFullTeamGitOps(t *testing.T) { appliedQueries = queries return nil } + var appliedSoftwareInstallers []*fleet.UploadSoftwareInstallerPayload ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + appliedSoftwareInstallers = installers return nil } - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { @@ -767,6 +831,9 @@ func TestFullTeamGitOps(t *testing.T) { enrolledSecrets = secrets return nil } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } startSoftwareInstallerServer(t) @@ -774,8 +841,8 @@ func TestFullTeamGitOps(t *testing.T) { // Dry run const baseFilename = "team_config_no_paths.yml" - file := "./testdata/gitops/" + baseFilename - _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) + gitopsFile := "./testdata/gitops/" + baseFilename + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"}) assert.Nil(t, savedTeam) assert.Len(t, enrolledSecrets, 0) assert.Len(t, appliedPolicySpecs, 0) @@ -783,13 +850,14 @@ func TestFullTeamGitOps(t *testing.T) { assert.Len(t, appliedScripts, 0) assert.Len(t, appliedMacProfiles, 0) assert.Len(t, appliedWinProfiles, 0) + assert.Empty(t, appliedSoftwareInstallers) // Real run // Setting global calendar config appConfig.Integrations = fleet.Integrations{ GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}}, } - _ = runAppForTest(t, []string{"gitops", "-f", file}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile}) require.NotNil(t, savedTeam) assert.Equal(t, teamName, savedTeam.Name) assert.Contains(t, string(*savedTeam.Config.AgentOptions), "distributed_denylist_duration") @@ -809,17 +877,30 @@ func TestFullTeamGitOps(t *testing.T) { require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) assert.Equal(t, baseFilename, *savedTeam.Filename) + require.Len(t, appliedSoftwareInstallers, 2) + packageID := `"ruby"` + uninstallScriptProcessed := strings.ReplaceAll(file.GetUninstallScript("deb"), "$PACKAGE_ID", packageID) + assert.ElementsMatch(t, []string{fmt.Sprintf("echo 'uninstall' %s\n", packageID), uninstallScriptProcessed}, + []string{appliedSoftwareInstallers[0].UninstallScript, appliedSoftwareInstallers[1].UninstallScript}) // Change team name newTeamName := "New Team Name" t.Setenv("TEST_TEAM_NAME", newTeamName) - _ = runAppForTest(t, []string{"gitops", "-f", file, "--dry-run"}) - _ = runAppForTest(t, []string{"gitops", "-f", file}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile, "--dry-run"}) + _ = runAppForTest(t, []string{"gitops", "-f", gitopsFile}) require.NotNil(t, savedTeam) assert.Equal(t, newTeamName, savedTeam.Name) assert.Equal(t, baseFilename, *savedTeam.Filename) + // Try to change team name again, but this time the new name conflicts with an existing team + t.Setenv("TEST_TEAM_NAME", "Conflict") + _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile, "--dry-run"}) + assert.ErrorContains(t, err, "team name already exists") + _, err = runAppNoChecks([]string{"gitops", "-f", gitopsFile}) + assert.ErrorContains(t, err, "team name already exists") + // Now clear the settings + t.Setenv("TEST_TEAM_NAME", newTeamName) tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) secret := "TestSecret" @@ -834,6 +915,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] +software: `, ) require.NoError(t, err) @@ -863,12 +945,13 @@ team_settings: assert.Equal(t, filepath.Base(tmpFile.Name()), *savedTeam.Filename) } -func TestBasicGlobalAndTeamGitOps(t *testing.T) { +func TestGitOpsBasicGlobalAndTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( t, &service.TestServerOpts{ - License: license, + License: license, + KeyValueStore: newMemKeyValueStore(), }, ) @@ -882,7 +965,7 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { return nil } - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { @@ -917,10 +1000,10 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, macProfiles) assert.Empty(t, winProfiles) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { assert.Empty(t, scripts) @@ -928,9 +1011,9 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, profileUUIDs) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -989,6 +1072,12 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -1011,6 +1100,7 @@ org_settings: org_logo_url_light_background: "" org_name: ${ORG_NAME} secrets: [{"secret":"globalSecret"}] +software: `, ) require.NoError(t, err) @@ -1030,6 +1120,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"}] +software: `, ) require.NoError(t, err) @@ -1045,6 +1136,7 @@ agent_options: name: ${TEST_TEAM_NAME} team_settings: secrets: [{"secret":"${TEST_SECRET}"},{"secret":"globalSecret"}] +software: `, ) require.NoError(t, err) @@ -1119,10 +1211,367 @@ team_settings: assert.True(t, ds.DeleteTeamFuncInvoked) } -func TestFullGlobalAndTeamGitOps(t *testing.T) { +func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { + // Cannot run t.Parallel() because runServerWithMockedDS sets the FLEET_SERVER_ADDRESS + // environment variable. + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + KeyValueStore: newMemKeyValueStore(), + }, + ) + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { + return nil + } + ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { + return nil + } + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Test" + secret = "TestSecret" + ) + var enrolledSecrets []*fleet.EnrollSecret + var enrolledTeamSecrets []*fleet.EnrollSecret + var savedTeam *fleet.Team + team := &fleet.Team{ + ID: 1, + CreatedAt: time.Now(), + Name: teamName, + } + + ds.IsEnrollSecretAvailableFunc = func(ctx context.Context, secret string, new bool, teamID *uint) (bool, error) { + return true, nil + } + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + if teamID == nil { + enrolledSecrets = secrets + } else { + enrolledTeamSecrets = secrets + } + return nil + } + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + assert.Empty(t, macProfiles) + assert.Empty(t, winProfiles) + return fleet.MDMProfilesUpdates{}, nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + assert.Empty(t, scripts) + return nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + assert.Empty(t, profileUUIDs) + return fleet.MDMProfilesUpdates{}, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListTeamPoliciesFunc = func( + ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, + ) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { + return nil, nil, nil + } + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return nil, nil + } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + ds.NewActivityFunc = func( + ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, + ) error { + return nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + job.ID = 1 + return job, nil + } + ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { + if tid == team.ID { + return savedTeam, nil + } + return nil, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + if name == teamName && savedTeam != nil { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { + if savedTeam != nil && *savedTeam.Filename == filename { + return savedTeam, nil + } + return nil, ¬FoundError{} + } + ds.NewTeamFunc = func(ctx context.Context, newTeam *fleet.Team) (*fleet.Team, error) { + newTeam.ID = team.ID + savedTeam = newTeam + enrolledTeamSecrets = newTeam.Secrets + return newTeam, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } + + globalFileBasic, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + _, err = globalFileBasic.WriteString(fmt.Sprintf( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithSoftware, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithSoftware.WriteString(fmt.Sprintf( + ` +controls: +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: + packages: + - url: https://example.com +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithControls, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithControls.WriteString(fmt.Sprintf( + ` +controls: + ios_updates: + deadline: "2022-02-02" + minimum_version: "17.6" +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +software: +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + globalFileWithoutControlsAndSoftwareKeys, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFileWithoutControlsAndSoftwareKeys.WriteString(fmt.Sprintf( + ` +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: %s + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: %s + secrets: [{"secret":"globalSecret"}] +`, fleetServerURL, orgName), + ) + require.NoError(t, err) + + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = teamFile.WriteString(fmt.Sprintf(` +controls: +queries: +policies: +agent_options: +name: %s +team_settings: + secrets: [{"secret":"%s"}] +software: +`, teamName, secret), + ) + require.NoError(t, err) + + noTeamFilePath := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFile, err := os.Create(noTeamFilePath) + require.NoError(t, err) + _, err = noTeamFile.WriteString(` +controls: +policies: +name: No team +software: +`) + require.NoError(t, err) + + noTeamFilePathPoliciesCalendarPath := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFilePathPoliciesCalendar, err := os.Create(noTeamFilePathPoliciesCalendarPath) + require.NoError(t, err) + _, err = noTeamFilePathPoliciesCalendar.WriteString(` +controls: +policies: + - name: Foobar + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + calendar_events_enabled: true +name: No team +software: +`) + require.NoError(t, err) + + noTeamFilePathWithControls := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFileWithControls, err := os.Create(noTeamFilePathWithControls) + require.NoError(t, err) + _, err = noTeamFileWithControls.WriteString(` +controls: + ipados_updates: + deadline: "2023-03-03" + minimum_version: "18.0" +policies: +name: No team +software: +`) + require.NoError(t, err) + + noTeamFilePathWithoutControls := filepath.Join(t.TempDir(), "no-team.yml") + noTeamFileWithoutControls, err := os.Create(noTeamFilePathWithoutControls) + require.NoError(t, err) + _, err = noTeamFileWithoutControls.WriteString(` +policies: +name: No team +software: +`) + require.NoError(t, err) + + // Dry run, global defines software, should fail. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file")) + // Real run, global defines software, should fail. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'software' cannot be set on global file")) + + // Dry run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) + // Real run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithControls.Name()}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml")) + + // Dry run, both global and no-team.yml defines policy with calendar events enabled. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFilePathPoliciesCalendar.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error()) + // Real run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFile.Name(), "-f", noTeamFilePathPoliciesCalendar.Name()}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error()) + + // Dry run, controls should be defined somewhere, either in no-team.yml or global. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name(), "--dry-run"}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) + // Real run, both global and no-team.yml define controls. + _, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFileWithoutControls.Name()}) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml")) + + // Dry run, global file without controls and software keys. + _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + + // Real run, global file without controls and software keys. + _ = runAppForTest(t, []string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Len(t, enrolledSecrets, 1) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 1) + assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) + + // Restore to test below. + savedAppConfig = &fleet.AppConfig{} + + // Dry run + _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", globalFileBasic.Name(), "-f", teamFile.Name(), "-f", noTeamFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Len(t, enrolledSecrets, 1) + require.NotNil(t, savedTeam) + assert.Equal(t, teamName, savedTeam.Name) + require.Len(t, enrolledTeamSecrets, 1) + assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) +} + +func TestGitOpsFullGlobalAndTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. - ds, savedAppConfigPtr, savedTeamPtr := setupFullGitOpsPremiumServer(t) + ds, savedAppConfigPtr, savedTeams := setupFullGitOpsPremiumServer(t) startSoftwareInstallerServer(t) var enrolledSecrets []*fleet.EnrollSecret @@ -1150,9 +1599,9 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { } ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { team.ID = 1 - *savedTeamPtr = team enrolledTeamSecrets = team.Secrets - return *savedTeamPtr, nil + savedTeams[team.Name] = &team + return team, nil } apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() @@ -1171,7 +1620,7 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { }, nil } - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { @@ -1181,11 +1630,6 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { globalFile := "./testdata/gitops/global_config_no_paths.yml" teamFile := "./testdata/gitops/team_config_no_paths.yml" - // Dry run on global file should fail because Apple BM Default Team does not exist (and has not been provided) - _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "--dry-run"}) - require.Error(t, err) - assert.True(t, strings.Contains(err.Error(), "team name not found")) - // Dry run _ = runAppForTest(t, []string{"gitops", "-f", globalFile, "-f", teamFile, "--dry-run", "--delete-other-teams"}) assert.False(t, ds.SaveAppConfigFuncInvoked) @@ -1199,30 +1643,31 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { assert.Equal(t, orgName, (*savedAppConfigPtr).OrgInfo.OrgName) assert.Equal(t, fleetServerURL, (*savedAppConfigPtr).ServerSettings.ServerURL) assert.Len(t, enrolledSecrets, 2) - require.NotNil(t, *savedTeamPtr) - assert.Equal(t, teamName, (*savedTeamPtr).Name) + require.NotNil(t, *savedTeams[teamName]) + assert.Equal(t, teamName, (*savedTeams[teamName]).Name) require.Len(t, enrolledTeamSecrets, 2) } -func TestTeamSofwareInstallersGitOps(t *testing.T) { +func TestGitOpsTeamSofwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { file string wantErr string }{ - {"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are publicy accessible to the internet."}, + {"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, - {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MB"}, + {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MiB"}, {"testdata/gitops/team_software_installer_valid.yml", ""}, {"testdata/gitops/team_software_installer_valid_apply.yml", ""}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_multiple_queries_apply.yml", "should have only one query."}, {"testdata/gitops/team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/team_software_installer_uninstall_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.packages of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { @@ -1238,7 +1683,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } } -func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) { +func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) { startSoftwareInstallerServer(t) ds, _, _ := setupFullGitOpsPremiumServer(t) @@ -1250,12 +1695,56 @@ func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) { } return nil } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } _, err := runAppNoChecks([]string{"gitops", "-f", "testdata/gitops/team_software_installer_valid_env_query.yml"}) require.NoError(t, err) } -func TestTeamVPPAppsGitOps(t *testing.T) { +func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { + startSoftwareInstallerServer(t) + + cases := []struct { + noTeamFile string + wantErr string + }{ + {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, + {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."}, + {"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 500 MiB"}, + {"testdata/gitops/no_team_software_installer_valid.yml", ""}, + {"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."}, + {"testdata/gitops/no_team_software_installer_pre_condition_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_uninstall_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, + {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, + {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, + } + for _, c := range cases { + t.Run(filepath.Base(c.noTeamFile), func(t *testing.T) { + setupFullGitOpsPremiumServer(t) + + t.Setenv("APPLE_BM_DEFAULT_TEAM", "") + globalFile := "./testdata/gitops/global_config_no_paths.yml" + dstPath := filepath.Join(filepath.Dir(c.noTeamFile), "no-team.yml") + t.Cleanup(func() { + os.Remove(dstPath) + }) + err := file.Copy(c.noTeamFile, dstPath, 0o755) + require.NoError(t, err) + _, err = runAppNoChecks([]string{"gitops", "-f", globalFile, "-f", dstPath}) + if c.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, c.wantErr) + } + }) + } +} + +func TestGitOpsTeamVPPApps(t *testing.T) { config := &appleVPPConfigSrvConf{ Assets: []vpp.Asset{ { @@ -1305,34 +1794,37 @@ func TestTeamVPPAppsGitOps(t *testing.T) { tokenExpiration time.Time }{ {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, - {"testdata/gitops/team_vpp_valid_app.yml", "", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_valid_app_self_service.yml", "", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_incorrect_type.yml", "\"app_store_apps.app_store_id\" must be a string, found number", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour)}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { ds, _, _ := setupFullGitOpsPremiumServer(t) - token, err := createVPPDataToken(c.tokenExpiration, "fleet", "ca") + token, err := test.CreateVPPTokenEncoded(c.tokenExpiration, "fleet", "ca") require.NoError(t, err) - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { - asset := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ - fleet.MDMAssetVPPToken: { - Name: fleet.MDMAssetVPPToken, - Value: token, - }, - } - return asset, nil + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ + ID: 1, + OrgName: "Fleet", + Location: "Earth", + RenewDate: c.tokenExpiration, + Token: string(token), + Teams: nil, + }, nil } _, err = runAppNoChecks([]string{"gitops", "-f", c.file}) @@ -1345,35 +1837,7 @@ func TestTeamVPPAppsGitOps(t *testing.T) { } } -func createVPPDataToken(expiration time.Time, orgName, location string) ([]byte, error) { - var randBytes [32]byte - _, err := rand.Read(randBytes[:]) - if err != nil { - return nil, fmt.Errorf("generating random bytes: %w", err) - } - token := base64.StdEncoding.EncodeToString(randBytes[:]) - raw := fleet.VPPTokenRaw{ - OrgName: orgName, - Token: token, - ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"), - } - rawJson, err := json.Marshal(raw) - if err != nil { - return nil, fmt.Errorf("marshalling vpp raw token: %w", err) - } - - base64Token := base64.StdEncoding.EncodeToString(rawJson) - - dataToken := fleet.VPPTokenData{Token: base64Token, Location: location} - dataTokenJson, err := json.Marshal(dataToken) - if err != nil { - return nil, fmt.Errorf("marshalling vpp data token: %w", err) - } - - return dataTokenJson, nil -} - -func TestCustomSettingsGitOps(t *testing.T) { +func TestGitOpsCustomSettings(t *testing.T) { cases := []struct { file string wantErr string @@ -1409,7 +1873,7 @@ func TestCustomSettingsGitOps(t *testing.T) { } return ret, nil } - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { @@ -1594,7 +2058,7 @@ func startVPPApplyServer(t *testing.T, config *appleVPPConfigSrvConf) { t.Cleanup(srv.Close) } -func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, **fleet.Team) { +func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, map[string]**fleet.Team) { testCert, testKey, err := apple_mdm.NewSCEPCACertKey() require.NoError(t, err) testCertPEM := tokenpki.PEMCertificate(testCert.Raw) @@ -1610,6 +2074,7 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, FleetConfig: &fleetCfg, License: license, NoCacheDatastore: true, + KeyValueStore: newMemKeyValueStore(), }, ) @@ -1628,14 +2093,14 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, savedAppConfig = &appConfigCopy return nil } - ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppID) error { + ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil } ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } - var savedTeam *fleet.Team + savedTeams := map[string]**fleet.Team{} ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { return nil @@ -1651,14 +2116,14 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -1677,8 +2142,12 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, return nil, nil, nil } ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { - if savedTeam != nil { - return []*fleet.Team{savedTeam}, nil + if savedTeams != nil { + var result []*fleet.Team + for _, t := range savedTeams { + result = append(result, *t) + } + return result, nil } return nil, nil } @@ -1696,33 +2165,39 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, return job, nil } ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - team.ID = 1 - savedTeam = team - return savedTeam, nil + team.ID = uint(len(savedTeams) + 1) + savedTeams[team.Name] = &team + return team, nil } ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string) (*fleet.Query, error) { return nil, ¬FoundError{} } ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { - if savedTeam != nil && tid == savedTeam.ID { - return savedTeam, nil + for _, tm := range savedTeams { + if (*tm).ID == tid { + return *tm, nil + } } return nil, ¬FoundError{} } ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { - if savedTeam != nil && name == teamName { - return savedTeam, nil + for _, tm := range savedTeams { + if (*tm).Name == name { + return *tm, nil + } } return nil, ¬FoundError{} } ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) { - if savedTeam != nil && *savedTeam.Filename == filename { - return savedTeam, nil + for _, tm := range savedTeams { + if *(*tm).Filename == filename { + return *tm, nil + } } return nil, ¬FoundError{} } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - savedTeam = team + savedTeams[team.Name] = &team return team, nil } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) ( @@ -1734,11 +2209,734 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + return nil, nil + } + + ds.InsertVPPTokenFunc = func(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{}, nil + } + ds.GetVPPTokenFunc = func(ctx context.Context, tokenID uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{}, err + } + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{}, nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return nil, nil + } + ds.UpdateVPPTokenTeamsFunc = func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) t.Setenv("TEST_TEAM_NAME", teamName) t.Setenv("APPLE_BM_DEFAULT_TEAM", teamName) - return ds, &savedAppConfig, &savedTeam + return ds, &savedAppConfig, savedTeams +} + +func TestGitOpsABM(t *testing.T) { + global := func(mdm string) string { + return fmt.Sprintf(` +controls: +queries: +policies: +agent_options: +software: +org_settings: + server_settings: + server_url: "https://foo.example.com" + org_info: + org_name: GitOps Test + secrets: + - secret: "global" + mdm: + %s + `, mdm) + } + + team := func(name string) string { + return fmt.Sprintf(` +name: %s +team_settings: + secrets: + - secret: "%s-secret" +agent_options: +controls: +policies: +queries: +software: +`, name, name) + } + + workstations := team("💻 Workstations") + iosTeam := team("📱🏢 Company-owned iPhones") + ipadTeam := team("🔳🏢 Company-owned iPads") + + cases := []struct { + name string + cfgs []string + dryRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) + realRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) + tokens []*fleet.ABMToken + }{ + { + name: "backwards compat", + cfgs: []string{ + global("apple_bm_default_team: 💻 Workstations"), + workstations, + }, + tokens: []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Equal(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam, "💻 Workstations") + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + // { + // name: "deprecated config with two tokens in the db fails", + // cfgs: []string{ + // global("apple_bm_default_team: 💻 Workstations"), + // workstations, + // }, + // tokens: []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Second Token LLC"}}, + // dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + // require.ErrorContains(t, err, "mdm.apple_bm_default_team has been deprecated") + // assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + // assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + // assert.NotContains(t, out, "[!] gitops dry run succeeded") + // }, + // realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + // require.ErrorContains(t, err, "mdm.apple_bm_default_team has been deprecated") + // assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + // assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + // assert.NotContains(t, out, "[!] gitops dry run succeeded") + // }, + // }, + { + name: "new key all valid", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Fleet Device Management Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads"`), + workstations, + iosTeam, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.ElementsMatch( + t, + appCfg.MDM.AppleBusinessManager.Value, + []fleet.MDMAppleABMAssignmentInfo{ + { + OrganizationName: "Fleet Device Management Inc.", + MacOSTeam: "💻 Workstations", + IOSTeam: "📱🏢 Company-owned iPhones", + IpadOSTeam: "🔳🏢 Company-owned iPads", + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "new key multiple elements", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Foo Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads" + - organization_name: Fleet Device Management Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads"`), + workstations, + iosTeam, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.ElementsMatch( + t, + appCfg.MDM.AppleBusinessManager.Value, + []fleet.MDMAppleABMAssignmentInfo{ + { + OrganizationName: "Fleet Device Management Inc.", + MacOSTeam: "💻 Workstations", + IOSTeam: "📱🏢 Company-owned iPhones", + IpadOSTeam: "🔳🏢 Company-owned iPads", + }, + { + OrganizationName: "Foo Inc.", + MacOSTeam: "💻 Workstations", + IOSTeam: "📱🏢 Company-owned iPhones", + IpadOSTeam: "🔳🏢 Company-owned iPads", + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "both keys errors", + cfgs: []string{ + global(` + apple_bm_default_team: "💻 Workstations" + apple_business_manager: + - organization_name: Fleet Device Management Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads"`), + workstations, + iosTeam, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + require.ErrorContains(t, err, "mdm.apple_bm_default_team has been deprecated") + assert.NotContains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + require.ErrorContains(t, err, "mdm.apple_bm_default_team has been deprecated") + assert.NotContains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "using an undefined team errors", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Fleet Device Management Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads"`), + workstations, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "apple_business_manager team \"📱🏢 Company-owned iPhones\" not found in team configs") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "apple_business_manager team \"📱🏢 Company-owned iPhones\" not found in team configs") + }, + }, + { + name: "no team is supported", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Fleet Device Management Inc. + macos_team: "No team" + ios_team: "No team" + ipados_team: "No team"`), + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.ElementsMatch( + t, + appCfg.MDM.AppleBusinessManager.Value, + []fleet.MDMAppleABMAssignmentInfo{ + { + OrganizationName: "Fleet Device Management Inc.", + MacOSTeam: "No team", + IOSTeam: "No team", + IpadOSTeam: "No team", + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "not provided teams defaults to no team", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Fleet Device Management Inc. + macos_team: "No team" + ios_team: ""`), + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.ElementsMatch( + t, + appCfg.MDM.AppleBusinessManager.Value, + []fleet.MDMAppleABMAssignmentInfo{ + { + OrganizationName: "Fleet Device Management Inc.", + MacOSTeam: "No team", + IOSTeam: "", + IpadOSTeam: "", + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "non existent org name fails", + cfgs: []string{ + global(` + apple_business_manager: + - organization_name: Does not exist + macos_team: "No team"`), + }, + tokens: []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "token with organization name Does not exist doesn't exist") + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.NotContains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "token with organization name Does not exist doesn't exist") + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) + assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + assert.NotContains(t, out, "[!] gitops dry run succeeded") + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + ds, savedAppConfigPtr, savedTeams := setupFullGitOpsPremiumServer(t) + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + if len(tt.tokens) > 0 { + return tt.tokens, nil + } + return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil + } + + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + var res []*fleet.TeamSummary + for _, tm := range savedTeams { + res = append(res, &fleet.TeamSummary{Name: (*tm).Name, ID: (*tm).ID}) + } + return res, nil + } + + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + args := []string{"gitops"} + for _, cfg := range tt.cfgs { + if cfg != "" { + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString(cfg) + require.NoError(t, err) + args = append(args, "-f", tmpFile.Name()) + } + } + + // Dry run + out, err := runAppNoChecks(append(args, "--dry-run")) + tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + if t.Failed() { + t.FailNow() + } + + // Real run + out, err = runAppNoChecks(args) + tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + + // Second real run, now that all the teams are saved + out, err = runAppNoChecks(args) + tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + }) + } +} + +func TestGitOpsVPP(t *testing.T) { + global := func(mdm string) string { + return fmt.Sprintf(` +controls: +queries: +policies: +agent_options: +software: +org_settings: + server_settings: + server_url: "https://foo.example.com" + org_info: + org_name: GitOps Test + secrets: + - secret: "global" + mdm: + %s + `, mdm) + } + + team := func(name string) string { + return fmt.Sprintf(` +name: %s +team_settings: + secrets: + - secret: "%s-secret" +agent_options: +controls: +policies: +queries: +software: +`, name, name) + } + + workstations := team("💻 Workstations") + iosTeam := team("📱🏢 Company-owned iPhones") + ipadTeam := team("🔳🏢 Company-owned iPads") + + cases := []struct { + name string + cfgs []string + dryRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) + realRunAssertion func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) + }{ + { + name: "new key all valid", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "💻 Workstations" + - "📱🏢 Company-owned iPhones" + - "🔳🏢 Company-owned iPads"`), + workstations, + iosTeam, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.ElementsMatch( + t, + appCfg.MDM.VolumePurchasingProgram.Value, + []fleet.MDMAppleVolumePurchasingProgramInfo{ + { + Location: "Fleet Device Management Inc.", + Teams: []string{ + "💻 Workstations", + "📱🏢 Company-owned iPhones", + "🔳🏢 Company-owned iPads", + }, + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "new key multiple elements", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Acme Inc. + teams: + - "💻 Workstations" + - location: Fleet Device Management Inc. + teams: + - "📱🏢 Company-owned iPhones" + - "🔳🏢 Company-owned iPads"`), + workstations, + iosTeam, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.ElementsMatch( + t, + appCfg.MDM.VolumePurchasingProgram.Value, + []fleet.MDMAppleVolumePurchasingProgramInfo{ + { + Location: "Acme Inc.", + Teams: []string{ + "💻 Workstations", + }, + }, + { + Location: "Fleet Device Management Inc.", + Teams: []string{ + "📱🏢 Company-owned iPhones", + "🔳🏢 Company-owned iPads", + }, + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "using an undefined team errors", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "💻 Workstations" + - "📱🏢 Company-owned iPhones" + - "🔳🏢 Company-owned iPads"`), + workstations, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "volume_purchasing_program team 📱🏢 Company-owned iPhones not found in team configs") + }, + }, + { + name: "no team is supported", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "💻 Workstations" + - "📱🏢 Company-owned iPhones" + - "No team"`), + workstations, + iosTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.ElementsMatch( + t, + appCfg.MDM.VolumePurchasingProgram.Value, + []fleet.MDMAppleVolumePurchasingProgramInfo{ + { + Location: "Fleet Device Management Inc.", + Teams: []string{ + "💻 Workstations", + "📱🏢 Company-owned iPhones", + "No team", + }, + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "all teams is supported", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "All teams"`), + workstations, + iosTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.ElementsMatch( + t, + appCfg.MDM.VolumePurchasingProgram.Value, + []fleet.MDMAppleVolumePurchasingProgramInfo{ + { + Location: "Fleet Device Management Inc.", + Teams: []string{ + "All teams", + }, + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "not provided teams defaults to no team", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams:`), + workstations, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.Contains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.NoError(t, err) + assert.ElementsMatch( + t, + appCfg.MDM.VolumePurchasingProgram.Value, + []fleet.MDMAppleVolumePurchasingProgramInfo{ + { + Location: "Fleet Device Management Inc.", + Teams: nil, + }, + }, + ) + assert.Contains(t, out, "[!] gitops succeeded") + }, + }, + { + name: "non existent location fails", + cfgs: []string{ + global(` + volume_purchasing_program: + - location: Does not exist + teams:`), + workstations, + ipadTeam, + }, + dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "token with location Does not exist doesn't exist") + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.NotContains(t, out, "[!] gitops dry run succeeded") + }, + realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { + assert.ErrorContains(t, err, "token with location Does not exist doesn't exist") + assert.Empty(t, appCfg.MDM.VolumePurchasingProgram.Value) + assert.NotContains(t, out, "[!] gitops dry run succeeded") + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + ds, savedAppConfigPtr, savedTeams := setupFullGitOpsPremiumServer(t) + + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{{Location: "Fleet Device Management Inc."}, {Location: "Acme Inc."}}, nil + } + + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}, {OrganizationName: "Foo Inc."}}, nil + } + + ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) { + var res []*fleet.TeamSummary + for _, tm := range savedTeams { + res = append(res, &fleet.TeamSummary{Name: (*tm).Name, ID: (*tm).ID}) + } + return res, nil + } + + ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { + return nil + } + + args := []string{"gitops"} + for _, cfg := range tt.cfgs { + if cfg != "" { + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = tmpFile.WriteString(cfg) + require.NoError(t, err) + args = append(args, "-f", tmpFile.Name()) + } + } + + // Dry run + out, err := runAppNoChecks(append(args, "--dry-run")) + tt.dryRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + if t.Failed() { + t.FailNow() + } + + // Real run + out, err = runAppNoChecks(args) + tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + + // Second real run, now that all the teams are saved + out, err = runAppNoChecks(args) + tt.realRunAssertion(t, *savedAppConfigPtr, ds, out.String(), err) + }) + } +} + +type memKeyValueStore struct { + m sync.Map +} + +func newMemKeyValueStore() *memKeyValueStore { + return &memKeyValueStore{} +} + +func (m *memKeyValueStore) Set(ctx context.Context, key string, value string, expireTime time.Duration) error { + m.m.Store(key, value) + return nil +} + +func (m *memKeyValueStore) Get(ctx context.Context, key string) (*string, error) { + v, ok := m.m.Load(key) + if !ok { + return nil, nil + } + vAsString := v.(string) + return &vAsString, nil } diff --git a/cmd/fleetctl/hosts_test.go b/cmd/fleetctl/hosts_test.go index a24fc79cbe..6220d6d75c 100644 --- a/cmd/fleetctl/hosts_test.go +++ b/cmd/fleetctl/hosts_test.go @@ -43,8 +43,9 @@ func TestHostsTransferByHosts(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -114,8 +115,9 @@ func TestHostsTransferByLabel(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -184,8 +186,9 @@ func TestHostsTransferByStatus(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -243,8 +246,9 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index a48734871c..ef06f3c8cc 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -74,7 +74,7 @@ func (d dockerCompose) Command(arg ...string) *exec.Cmd { func newDockerCompose() (dockerCompose, error) { // first, check if `docker compose` is available - if err := exec.Command("docker compose").Run(); err == nil { + if err := exec.Command("docker", "compose").Run(); err == nil { return dockerCompose{dockerComposeV2}, nil } @@ -387,7 +387,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st } // this only applies standard queries, the base directory is not used, // so pass in the current working directory. - _, err = client.ApplyGroup(c.Context, specs, ".", logf, fleet.ApplyClientSpecOptions{}) + _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) if err != nil { return err } diff --git a/cmd/fleetctl/preview_test.go b/cmd/fleetctl/preview_test.go index 76c68eb190..c3a75a73e1 100644 --- a/cmd/fleetctl/preview_test.go +++ b/cmd/fleetctl/preview_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestPreview(t *testing.T) { +func TestIntegrationsPreview(t *testing.T) { nettest.Run(t) t.Setenv("FLEET_SERVER_ADDRESS", "https://localhost:8412") @@ -74,6 +74,7 @@ func gitRootPath(t *testing.T) string { } func TestDockerCompose(t *testing.T) { + t.Parallel() t.Run("returns the right command according to the version", func(t *testing.T) { v1 := dockerCompose{dockerComposeV1} cmd1 := v1.Command("up") diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 399e880a93..ece537e7b9 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -96,7 +96,8 @@ "apple_bm_terms_expired": false, "apple_bm_enabled_and_configured": false, "enabled_and_configured": false, - "apple_bm_default_team": "", + "apple_business_manager": null, + "volume_purchasing_program": null, "windows_enabled_and_configured": false, "enable_disk_encryption": false, "macos_updates": { diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index b76d40bd52..5f19ffcb8a 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -21,7 +21,8 @@ spec: apple_bm_terms_expired: false apple_bm_enabled_and_configured: false enabled_and_configured: false - apple_bm_default_team: "" + apple_business_manager: null + volume_purchasing_program: null windows_enabled_and_configured: false enable_disk_encryption: false macos_migration: diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 76a89e6493..9fa625b676 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -46,7 +46,8 @@ "enable_software_inventory": false }, "mdm": { - "apple_bm_default_team": "", + "apple_business_manager": null, + "volume_purchasing_program": null, "apple_bm_terms_expired": false, "apple_bm_enabled_and_configured": false, "enabled_and_configured": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index f6ca79a4eb..b28ab395b3 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -18,7 +18,8 @@ spec: jira: null zendesk: null mdm: - apple_bm_default_team: "" + apple_business_manager: null + volume_purchasing_program: null apple_bm_enabled_and_configured: false apple_bm_terms_expired: false enabled_and_configured: false diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 76936e3ad5..894841a933 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -146,7 +146,6 @@ org_settings: "private_key": "google_calendar_private_key", } mdm: - apple_bm_default_team: $APPLE_BM_DEFAULT_TEAM end_user_authentication: entity_id: "" idp_name: "" @@ -187,3 +186,4 @@ org_settings: secrets: # These secrets are used to enroll hosts to the "All teams" team - secret: SampleSecret123 - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml index 177c1c80cf..b098442585 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_custom_settings_valid_deprecated.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml index da75847cd5..e6231bf030 100644 --- a/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml +++ b/cmd/fleetctl/testdata/gitops/global_macos_windows_custom_settings_valid.yml @@ -97,3 +97,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml index 9d2ac6e69f..1a100de6f3 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml @@ -93,3 +93,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml index ba1d06f784..5208ba7248 100644 --- a/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml +++ b/cmd/fleetctl/testdata/gitops/global_windows_custom_settings_unknown_label.yml @@ -91,3 +91,4 @@ org_settings: databases_path: "" secrets: - secret: ABC +software: \ No newline at end of file diff --git a/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh new file mode 100644 index 0000000000..c6c41b5e01 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/lib/uninstall_ruby.sh @@ -0,0 +1 @@ +echo 'uninstall' ${PACKAGE_ID} diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml new file mode 100644 index 0000000000..58bae27ae9 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_install_not_found.yml @@ -0,0 +1,8 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml new file mode 100644 index 0000000000..b333e7816e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_invalid_self_service_value.yml @@ -0,0 +1,7 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt + self_service: "not a boolean" diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml new file mode 100644 index 0000000000..d897af7b43 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_no_url.yml @@ -0,0 +1,11 @@ +name: No TEAM +controls: +policies: +software: + packages: + - install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml new file mode 100644 index 0000000000..590458e78b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_not_found.yml @@ -0,0 +1,6 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/notfound.deb diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml new file mode 100644 index 0000000000..12b2598d59 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_post_install_not_found.yml @@ -0,0 +1,10 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + post_install_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml new file mode 100644 index 0000000000..15ddcb438c --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml @@ -0,0 +1,12 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_multiple.yml + post_install_script: + path: lib/post_install_ruby.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml new file mode 100644 index 0000000000..48e6ff42e5 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_pre_condition_not_found.yml @@ -0,0 +1,10 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/notfound.yml diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml new file mode 100644 index 0000000000..23ba8dbe80 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_too_large.yml @@ -0,0 +1,6 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/toolarge.deb diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml new file mode 100644 index 0000000000..c5c8838267 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_uninstall_not_found.yml @@ -0,0 +1,8 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + uninstall_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml new file mode 100644 index 0000000000..ace876a8d5 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_unsupported.yml @@ -0,0 +1,6 @@ +name: "No team" +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt diff --git a/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml new file mode 100644 index 0000000000..4599698d1d --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/no_team_software_installer_valid.yml @@ -0,0 +1,16 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index 785ba5d215..e671d17d29 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -124,5 +124,7 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml new file mode 100644 index 0000000000..1fc9903d6b --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_uninstall_not_found.yml @@ -0,0 +1,19 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + uninstall_script: + path: lib/notfound.sh diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index e894112249..0733758ced 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -21,5 +21,7 @@ software: path: lib/query_ruby.yml post_install_script: path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh - url: ${SOFTWARE_INSTALLER_URL}/other.deb self_service: true diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_empty_adamid.yml b/cmd/fleetctl/testdata/gitops/team_vpp_empty_adamid.yml new file mode 100644 index 0000000000..675618c609 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_empty_adamid.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml new file mode 100644 index 0000000000..74e7b17806 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: 1 diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_self_service.yml b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_self_service.yml new file mode 100644 index 0000000000..a2daad396e --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_valid_app_self_service.yml @@ -0,0 +1,18 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + self_service: true diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index fc886f7658..02adaad3ac 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -18,7 +18,8 @@ spec: jira: null zendesk: null mdm: - apple_bm_default_team: "" + apple_business_manager: + volume_purchasing_program: apple_bm_enabled_and_configured: false apple_bm_terms_expired: false enabled_and_configured: true diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 5a57cc2500..2dd2f93adf 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -18,7 +18,8 @@ spec: jira: null zendesk: null mdm: - apple_bm_default_team: "" + apple_business_manager: + volume_purchasing_program: apple_bm_enabled_and_configured: false apple_bm_terms_expired: false enabled_and_configured: true diff --git a/cmd/fleetctl/testing_utils.go b/cmd/fleetctl/testing_utils.go index 7a278ebc3c..918a12b94e 100644 --- a/cmd/fleetctl/testing_utils.go +++ b/cmd/fleetctl/testing_utils.go @@ -127,24 +127,24 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http require.NoError(t, err) ds.GetAllMDMConfigAssetsHashesFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]string, error) { return map[fleet.MDMAssetName]string{ - fleet.MDMAssetABMCert: "abmcert", - fleet.MDMAssetABMKey: "abmkey", - fleet.MDMAssetABMToken: "abmtoken", - fleet.MDMAssetAPNSCert: "apnscert", - fleet.MDMAssetAPNSKey: "apnskey", - fleet.MDMAssetCACert: "scepcert", - fleet.MDMAssetCAKey: "scepkey", + fleet.MDMAssetABMCert: "abmcert", + fleet.MDMAssetABMKey: "abmkey", + fleet.MDMAssetABMTokenDeprecated: "abmtoken", + fleet.MDMAssetAPNSCert: "apnscert", + fleet.MDMAssetAPNSKey: "apnskey", + fleet.MDMAssetCACert: "scepcert", + fleet.MDMAssetCAKey: "scepkey", }, nil } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ - fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, - fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, - fleet.MDMAssetABMToken: {Name: fleet.MDMAssetABMToken, Value: tokenBytes}, - fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert}, - fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey}, - fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM}, - fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM}, + fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM}, + fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM}, + fleet.MDMAssetABMTokenDeprecated: {Name: fleet.MDMAssetABMTokenDeprecated, Value: tokenBytes}, + fleet.MDMAssetAPNSCert: {Name: fleet.MDMAssetAPNSCert, Value: apnsCert}, + fleet.MDMAssetAPNSKey: {Name: fleet.MDMAssetAPNSKey, Value: apnsKey}, + fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: certPEM}, + fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: keyPEM}, }, nil } diff --git a/cmd/fleetctl/vulnerability_data_stream_test.go b/cmd/fleetctl/vulnerability_data_stream_test.go index 0e61949eea..d1316a3251 100644 --- a/cmd/fleetctl/vulnerability_data_stream_test.go +++ b/cmd/fleetctl/vulnerability_data_stream_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestVulnerabilityDataStream(t *testing.T) { +func TestIntegrationsVulnerabilityDataStream(t *testing.T) { nettest.Run(t) runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided") diff --git a/cmd/osquery-perf/README.md b/cmd/osquery-perf/README.md index 1641283d59..eb179dd13c 100644 --- a/cmd/osquery-perf/README.md +++ b/cmd/osquery-perf/README.md @@ -111,3 +111,11 @@ Example of running the agent with MDM. Note that `enroll_secret` is not needed f ``` go run agent.go --os_templates ipad_13.18,iphone_14.6 --host_count 10 --mdm_scep_challenge 0d53306e-6d7a-9d14-a372-f9e53f9d62db ``` + +## Installing software + +The agent can install software for "macos", "ubuntu", and "windows" OSs when running with orbit agent. The following options control the installation behavior: + +- `--software_installer_pre_install_fail_prob`: default 0.05, `select 1` always passes and `select 0` always fails +- `--software_installer_install_fail_prob`: default 0.05, `exit 0` always passes and `exit 1` always fails +- `--software_installer_post_install_fail_prob`: default 0.05, `exit 0` always passes and `exit 1` always fails diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 04851dfc41..10e8ee21b0 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -25,6 +25,8 @@ import ( "text/template" "time" + "github.com/fleetdm/fleet/v4/cmd/osquery-perf/installer_cache" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -54,6 +56,8 @@ var ( vsCodeExtensionsVulnerableSoftware []fleet.Software windowsSoftware []map[string]string ubuntuSoftware []map[string]string + + installerMetadataCache installer_cache.Metadata ) func loadMacOSVulnerableSoftware() { @@ -490,6 +494,11 @@ type agent struct { softwareQueryFailureProb float64 softwareVSCodeExtensionsFailProb float64 + softwareInstaller softwareInstaller + + // Software installed on the host via Fleet. Key is the software name + version + bundle identifier. + installedSoftware sync.Map + // // The following are exported to be used by the templates. // @@ -544,6 +553,12 @@ type softwareExtraEntityCount struct { uniqueSoftwareUninstallCount int uniqueSoftwareUninstallProb float64 } +type softwareInstaller struct { + preInstallFailureProb float64 + installFailureProb float64 + postInstallFailureProb float64 + mu *sync.Mutex +} func newAgent( agentIndex int, @@ -552,6 +567,7 @@ func newAgent( configInterval, logInterval, queryInterval, mdmCheckInInterval time.Duration, softwareQueryFailureProb float64, softwareVSCodeExtensionsQueryFailureProb float64, + softwareInstaller softwareInstaller, softwareCount softwareEntityCount, softwareVSCodeExtensionsCount softwareExtraEntityCount, userCount entityCount, @@ -642,6 +658,7 @@ func newAgent( softwareQueryFailureProb: softwareQueryFailureProb, softwareVSCodeExtensionsFailProb: softwareVSCodeExtensionsQueryFailureProb, + softwareInstaller: softwareInstaller, macMDMClient: macMDMClient, winMDMClient: winMDMClient, @@ -967,6 +984,11 @@ func (a *agent) runOrbitLoop() { // that will simulate executing them. go a.execScripts(cfg.Notifications.PendingScriptExecutionIDs, orbitClient) } + if len(cfg.Notifications.PendingSoftwareInstallerIDs) > 0 { + // there are pending software installations on this host, start a + // goroutine that will download the software + go a.installSoftware(cfg.Notifications.PendingSoftwareInstallerIDs, orbitClient) + } if cfg.Notifications.NeedsProgrammaticWindowsMDMEnrollment && !a.mdmEnrolled() && a.winMDMClient != nil && @@ -1242,6 +1264,130 @@ func (a *agent) execScripts(execIDs []string, orbitClient *service.OrbitClient) } } +func (a *agent) installSoftware(installerIDs []string, orbitClient *service.OrbitClient) { + // Only allow one software install to happen at a time. + if a.softwareInstaller.mu.TryLock() { + defer a.softwareInstaller.mu.Unlock() + for _, installerID := range installerIDs { + a.installSoftwareItem(installerID, orbitClient) + } + } +} + +func (a *agent) installSoftwareItem(installerID string, orbitClient *service.OrbitClient) { + payload := &fleet.HostSoftwareInstallResultPayload{} + payload.InstallUUID = installerID + installer, err := orbitClient.GetInstallerDetails(installerID) + if err != nil { + log.Println("get installer details:", err) + return + } + failed := false + if installer.PreInstallCondition != "" { + time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) + if installer.PreInstallCondition == "select 1" { + // Always pass + payload.PreInstallConditionOutput = ptr.String("1") + } else if installer.PreInstallCondition == "select 0" || + a.softwareInstaller.preInstallFailureProb > 0.0 && rand.Float64() <= a.softwareInstaller.preInstallFailureProb { + // Fail + payload.PreInstallConditionOutput = ptr.String("") + failed = true + } else { + payload.PreInstallConditionOutput = ptr.String("1") + } + } + + var meta *file.InstallerMetadata + if !failed { + var cacheMiss bool + // Download the file if needed to get its metadata + meta, cacheMiss, err = installerMetadataCache.Get(installer.InstallerID, orbitClient) + if err != nil { + return + } + + if !cacheMiss { + // If we didn't download and analyze the file, we do a download and don't save the result + err = orbitClient.DownloadAndDiscardSoftwareInstaller(installer.InstallerID) + if err != nil { + log.Println("download and discard software installer:", err) + return + } + } + + time.Sleep(time.Duration(rand.Intn(30)) * time.Second) + if installer.InstallScript == "exit 0" { + // Always pass + payload.InstallScriptExitCode = ptr.Int(0) + payload.InstallScriptOutput = ptr.String("Installed on osquery-perf (always pass)") + } else if installer.InstallScript == "exit 1" { + payload.InstallScriptExitCode = ptr.Int(1) + payload.InstallScriptOutput = ptr.String("Installed on osquery-perf (always fail)") + failed = true + } else if a.softwareInstaller.installFailureProb > 0.0 && rand.Float64() <= a.softwareInstaller.installFailureProb { + payload.InstallScriptExitCode = ptr.Int(1) + payload.InstallScriptOutput = ptr.String("Installed on osquery-perf (fail)") + failed = true + } else { + payload.InstallScriptExitCode = ptr.Int(0) + payload.InstallScriptOutput = ptr.String("Installed on osquery-perf (pass)") + } + } + if !failed { + if meta.Name == "" { + log.Printf("WARNING: installer metadata is missing a name for installer:%d\n", installer.InstallerID) + } else { + key := meta.Name + "+" + meta.Version + "+" + meta.BundleIdentifier + if _, ok := a.installedSoftware.Load(key); !ok { + source := "" + switch a.os { + case "macos": + source = "apps" + case "windows": + source = "programs" + case "ubuntu": + source = "deb_packages" + default: + log.Printf("unknown OS to software installer: %s", a.os) + return + } + a.installedSoftware.Store(key, map[string]string{ + "name": meta.Name, + "version": meta.Version, + "bundle_identifier": meta.BundleIdentifier, + "source": source, + "installed_path": os.DevNull, + }) + } + } + + if installer.PostInstallScript != "" { + time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) + if installer.PostInstallScript == "exit 0" { + // Always pass + payload.PostInstallScriptExitCode = ptr.Int(0) + payload.PostInstallScriptOutput = ptr.String("PostInstall on osquery-perf (always pass)") + } else if installer.PostInstallScript == "exit 1" { + payload.PostInstallScriptExitCode = ptr.Int(1) + payload.PostInstallScriptOutput = ptr.String("PostInstall on osquery-perf (always fail)") + } else if a.softwareInstaller.postInstallFailureProb > 0.0 && rand.Float64() <= a.softwareInstaller.postInstallFailureProb { + payload.PostInstallScriptExitCode = ptr.Int(1) + payload.PostInstallScriptOutput = ptr.String("PostInstall on osquery-perf (fail)") + } else { + payload.PostInstallScriptExitCode = ptr.Int(0) + payload.PostInstallScriptOutput = ptr.String("PostInstall on osquery-perf (pass)") + } + } + } + + err = orbitClient.SaveInstallerResult(payload) + if err != nil { + log.Println("save installer result:", err) + return + } +} + func (a *agent) waitingDo(fn func() *http.Request) *http.Response { response, err := http.DefaultClient.Do(fn()) for err != nil || response.StatusCode != http.StatusOK { @@ -1525,6 +1671,10 @@ func (a *agent) softwareMacOS() []map[string]string { } software := append(commonSoftware, uniqueSoftware...) software = append(software, randomVulnerableSoftware...) + a.installedSoftware.Range(func(key, value interface{}) bool { + software = append(software, value.(map[string]string)) + return true + }) rand.Shuffle(len(software), func(i, j int) { software[i], software[j] = software[j], software[i] }) @@ -2023,6 +2173,10 @@ func (a *agent) processQuery(name, query string) ( } if ss == fleet.StatusOK { results = windowsSoftware + a.installedSoftware.Range(func(key, value interface{}) bool { + results = append(results, value.(map[string]string)) + return true + }) } return true, results, &ss, nil, nil case name == hostDetailQueryPrefix+"software_linux": @@ -2034,6 +2188,10 @@ func (a *agent) processQuery(name, query string) ( switch a.os { case "ubuntu": results = ubuntuSoftware + a.installedSoftware.Range(func(key, value interface{}) bool { + results = append(results, value.(map[string]string)) + return true + }) } } return true, results, &ss, nil, nil @@ -2351,7 +2509,7 @@ func main() { // osquery-perf will send log requests with results only if there are scheduled queries configured AND it's their time to run. logInterval = flag.Duration("logger_tls_period", 10*time.Second, "Interval for scheduled queries log requests") queryInterval = flag.Duration("query_interval", 10*time.Second, "Interval for distributed query requests") - mdmCheckInInterval = flag.Duration("mdm_check_in_interval", 10*time.Second, "Interval for performing MDM check-ins (applies to both macOS and Windows)") + mdmCheckInInterval = flag.Duration("mdm_check_in_interval", 1*time.Minute, "Interval for performing MDM check-ins (applies to both macOS and Windows)") onlyAlreadyEnrolled = flag.Bool("only_already_enrolled", false, "Only start agents that are already enrolled") nodeKeyFile = flag.String("node_key_file", "", "File with node keys to use") @@ -2361,6 +2519,13 @@ func main() { softwareQueryFailureProb = flag.Float64("software_query_fail_prob", 0.5, "Probability of the software query failing") softwareVSCodeExtensionsQueryFailureProb = flag.Float64("software_vscode_extensions_query_fail_prob", 0.0, "Probability of the software vscode_extensions query failing") + softwareInstallerPreInstallFailureProb = flag.Float64("software_installer_pre_install_fail_prob", 0.05, + "Probability of the pre-install query failing") + softwareInstallerInstallFailureProb = flag.Float64("software_installer_install_fail_prob", 0.05, + "Probability of the install script failing") + softwareInstallerPostInstallFailureProb = flag.Float64("software_installer_post_install_fail_prob", 0.05, + "Probability of the post-install script failing") + commonSoftwareCount = flag.Int("common_software_count", 10, "Number of common installed applications reported to fleet") commonVSCodeExtensionsSoftwareCount = flag.Int("common_vscode_extensions_software_count", 5, "Number of common vscode_extensions installed applications reported to fleet") commonSoftwareUninstallCount = flag.Int("common_software_uninstall_count", 1, "Number of common software to uninstall") @@ -2527,6 +2692,12 @@ func main() { *mdmCheckInInterval, *softwareQueryFailureProb, *softwareVSCodeExtensionsQueryFailureProb, + softwareInstaller{ + preInstallFailureProb: *softwareInstallerPreInstallFailureProb, + installFailureProb: *softwareInstallerInstallFailureProb, + postInstallFailureProb: *softwareInstallerPostInstallFailureProb, + mu: new(sync.Mutex), + }, softwareEntityCount{ entityCount: entityCount{ common: *commonSoftwareCount, diff --git a/cmd/osquery-perf/installer_cache/installer-cache.go b/cmd/osquery-perf/installer_cache/installer-cache.go new file mode 100644 index 0000000000..57994de5f6 --- /dev/null +++ b/cmd/osquery-perf/installer_cache/installer-cache.go @@ -0,0 +1,66 @@ +package installer_cache + +import ( + "log" + "os" + "sync" + + "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/server/service" +) + +// Metadata holds the metadata for software installers. +// To extract the metadata, we must download the file. Once the file has been downloaded once and analyzed, +// the other agents can use the cache to get the appropriate metadata. +type Metadata struct { + mu sync.Mutex + cache map[uint]*file.InstallerMetadata +} + +func (c *Metadata) Get(key uint, orbitClient *service.OrbitClient) (meta *file.InstallerMetadata, + cacheMiss bool, err error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cache == nil { + c.cache = make(map[uint]*file.InstallerMetadata, 1) + } + + meta, ok := c.cache[key] + if !ok { + var err error + meta, err = populateMetadata(orbitClient, key) + if err != nil { + return nil, false, err + } + c.cache[key] = meta + cacheMiss = true + } + return meta, cacheMiss, nil +} + +func populateMetadata(orbitClient *service.OrbitClient, installerID uint) (*file.InstallerMetadata, error) { + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + log.Println("create temp dir:", err) + return nil, err + } + defer os.RemoveAll(tmpDir) + path, err := orbitClient.DownloadSoftwareInstaller(installerID, tmpDir) + if err != nil { + log.Println("download software installer:", err) + return nil, err + } + // Figure out what we're actually installing here and add it to software inventory + f, err := os.Open(path) + if err != nil { + log.Println("open installer:", err) + return nil, err + } + defer f.Close() + item, err := file.ExtractInstallerMetadata(f) + if err != nil { + log.Println("extract installer metadata:", err) + return nil, err + } + return item, nil +} diff --git a/cmd/osquery-perf/macos_13.6.2.tmpl b/cmd/osquery-perf/macos_13.6.2.tmpl index 8f59d00e9e..83ee06cc5f 100644 --- a/cmd/osquery-perf/macos_13.6.2.tmpl +++ b/cmd/osquery-perf/macos_13.6.2.tmpl @@ -173,6 +173,10 @@ {{- end }} {{- end }} +{{ define "fleet_detail_query_mdm_config_profiles_darwin" -}} +[] +{{- end }} + {{/* all hosts */}} {{ define "fleet_label_query_6" -}} [ diff --git a/cmd/osquery-perf/macos_14.1.2.tmpl b/cmd/osquery-perf/macos_14.1.2.tmpl index 8aa29e1c4a..4c768d34c1 100644 --- a/cmd/osquery-perf/macos_14.1.2.tmpl +++ b/cmd/osquery-perf/macos_14.1.2.tmpl @@ -174,6 +174,10 @@ {{- end }} {{- end }} +{{ define "fleet_detail_query_mdm_config_profiles_darwin" -}} +[] +{{- end }} + {{/* all hosts */}} {{ define "fleet_label_query_6" -}} [ diff --git a/codecov.yml b/codecov.yml index d0a398457d..cac1d5c0d1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -22,3 +22,7 @@ flag_management: - name: frontend paths: - frontend/ + +ignore: + - "server/mock" + - "server/fleet/activities.go" # mostly contains code for documentation -- not interesting for tests diff --git a/docker-compose.yml b/docker-compose.yml index 437e5ddc71..6a393010e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,12 +167,6 @@ services: volumes: - data-minio:/data - toxiproxy: - image: shopify/toxiproxy - ports: - - "22220:22220" - - "8474:8474" - volumes: mysql-persistent-volume: data-minio: diff --git a/docs/Configuration/fleet-server-configuration.md b/docs/Configuration/fleet-server-configuration.md index 76e25658d6..89fa9072ee 100644 --- a/docs/Configuration/fleet-server-configuration.md +++ b/docs/Configuration/fleet-server-configuration.md @@ -4,65 +4,16 @@ Fleet server configuration options update the internals of the Fleet server (MyS Only self-managed users and customers can modify this configuration. If you're a managed-cloud customer, please reach out to Fleet about modifying the configuration. +## Configuration options + You can specify configuration options in the following formats: 1. YAML file 2. Environment variables 3. Command-line flags -All duration-based settings accept valid time units of `s`, `m`, `h`. - -## YAML file - -```sh -echo ' - -mysql: - address: 127.0.0.1:3306 - database: fleet - username: root - password: toor -redis: - address: 127.0.0.1:6379 -server: - cert: /tmp/server.cert - key: /tmp/server.key -logging: - json: true -' > /tmp/fleet.yml -fleet serve --config /tmp/fleet.yml -``` - -## Environment variables - -```sh -FLEET_MYSQL_ADDRESS=127.0.0.1:3306 \ -FLEET_MYSQL_DATABASE=fleet \ -FLEET_MYSQL_USERNAME=root \ -FLEET_MYSQL_PASSWORD=toor \ -FLEET_REDIS_ADDRESS=127.0.0.1:6379 \ -FLEET_SERVER_CERT=/tmp/server.cert \ -FLEET_SERVER_KEY=/tmp/server.key \ -FLEET_LOGGING_JSON=true \ -/usr/bin/fleet serve -``` - -## Command-line flags - -```sh -/usr/bin/fleet serve \ ---mysql_address=127.0.0.1:3306 \ ---mysql_database=fleet \ ---mysql_username=root \ ---mysql_password=toor \ ---redis_address=127.0.0.1:6379 \ ---server_cert=/tmp/server.cert \ ---server_key=/tmp/server.key \ ---logging_json -``` - - -## Configuration options +- All duration-based settings accept valid time units of `s`, `m`, `h`. +- Command-line flags can also be piped in via stdin. #### MySQL @@ -2203,7 +2154,7 @@ for the email address specified in the Source parameter of SendRawEmail. ##### s3_software_installers_bucket -Name of the S3 bucket for storing software. +Name of the S3 bucket for storing software and bootstrap package. - Default value: none - Environment variable: `FLEET_S3_SOFTWARE_INSTALLERS_BUCKET` @@ -2891,7 +2842,7 @@ packaging: region: us-east-1 ``` -## Mobile device management (MDM) +#### Mobile device management (MDM) > The [`server_private_key` configuration option](#server_private_key) is required for macOS MDM features. @@ -2962,11 +2913,6 @@ The content of the Windows WSTEP identity key. An RSA private key, PEM-encoded. -----END RSA PRIVATE KEY----- ``` - -## Managing osquery configurations - -We recommend that you use an infrastructure configuration management tool to manage these osquery configurations consistently across your environment. If you're unsure about what configuration management tools your organization uses, contact your company's system administrators. If you are evaluating new solutions for this problem, the founders of Fleet have successfully managed configurations in large production environments using [Chef](https://www.chef.io/chef/) and [Puppet](https://puppet.com/). -

Running with systemd

This content was moved to [Systemd](http://fleetdm.com/docs/deploy/system-d) on Sept 6th, 2023. diff --git a/docs/Using Fleet/GitOps.md b/docs/Configuration/yaml-files.md similarity index 74% rename from docs/Using Fleet/GitOps.md rename to docs/Configuration/yaml-files.md index ce2680bda6..7599fd259f 100644 --- a/docs/Using Fleet/GitOps.md +++ b/docs/Configuration/yaml-files.md @@ -1,4 +1,4 @@ -# GitOps +# YAML files Use Fleet's best practice GitOps workflow to manage your computers as code. @@ -6,14 +6,16 @@ To learn how to set up a GitOps workflow see the [Fleet GitOps repo](https://git ## File structure -- `default.yml`- File where you define the queries, policies, controls, and agent options for all hosts. If you're using Fleet Premium, this file updates queries and policies that run on all hosts ("All teams"). Controls and agent options are defined for hosts on "No team." -- `teams/` - Folder where you define your teams in Fleet. These `teams/team-name.yml` files define the controls, queries, policies, and agent options for hosts assigned to the specified team. Teams are available in Fleet Premium. +- `default.yml` - File where you define the queries, policies and agent options for all hosts. If you're using Fleet Premium, this file updates queries and policies that run on all hosts ("All teams"). +- `teams/no-team.yml` - File where you define the policies, controls, and software for hosts on "No team". Available in Fleet Premium. +- `teams/` - Folder where you define your teams in Fleet. These `teams/team-name.yml` files define the controls, queries, policies, software, and agent options for hosts assigned to the specified team. Available in Fleet Premium. - `lib/` - Folder where you define policies, queries, configuration profiles, scripts, and agent options. These files can be referenced in top level keys in the `default.yml` file and the files in the `teams/` folder. - `.github/workflows/workflow.yml` - The GitHub workflow file where you can add [environment variables](https://docs.github.com/en/actions/learn-github-actions/variables#defining-environment-variables-for-a-single-workflow). -The following files are responsible for running the GitHub action. Most users don't need to edit these files. +The following files are responsible for running the GitHub action or GitLab CI/CD. Most users don't need to edit these files. - `gitops.sh` - The bash script that applies the latest configuration to Fleet. This script is used in the GitHub action file. - `.github/gitops-action/action.yml` - The GitHub action that runs `gitops.sh`. This action is used in the GitHub workflow file. It can also be used in other workflows. +- `.gitlab-ci.yml` - The GitLab CI/CD file that applies the latest configuration to Fleet. ## Configuration options @@ -24,8 +26,7 @@ name: # Only teams/team-name.yml. To edit a team's name, change `name` but don't policies: queries: agent_options: -controls: -software: +controls: # Can be defined in teams/no-team.yml too. org_settings: # Only default.yml team_settings: # Only teams/team-name.yml ``` @@ -40,6 +41,8 @@ team_settings: # Only teams/team-name.yml ### policies Polcies can be specified inline in your `default.yml` file or `teams/team-name.yml` files. They can also be specified in separate files in your `lib/` folder. +Policies defined in `default.yml` run on **all** hosts. +Policies defined in `teams/no-team.yml` run on hosts that belong to "No team". #### Options @@ -81,9 +84,16 @@ policies: platform: darwin critical: false calendar_event_enabled: false +- name: Firefox on Linux installed and up to date + platform: linux + description: "This policy checks that Firefox is installed and up to date." + resolution: "Install Firefox version 129.0.2 or higher." + query: "SELECT 1 FROM deb_packages WHERE name = 'firefox' AND version_compare(version, '129.0.2') >= 0;" + install_software: + package_path: "../lib/linux-firefox.deb.package.yml" ``` -`default.yml` or `teams/team-name.yml` +`default.yml`, `teams/team-name.yml`, or `teams/no-team.yml` ```yaml policies: @@ -210,6 +220,8 @@ queries: The `controls` section allows you to configure scripts and device management (MDM) features in Fleet. +Controls for hosts that are in "No team" can be defined in `default.yml` or in `teams/no-team.yml` (but not in both files). + - `scripts` is a list of paths to macOS, Windows, or Linux scripts. - `windows_enabled_and_configured` specifies whether or not to turn on Windows MDM features (default: `false`). Can only be configured for all teams (`default.yml`). - `enable_disk_encryption` specifies whether or not to enforce disk encryption on macOS and Windows hosts (default: `false`). @@ -262,6 +274,16 @@ controls: - `deadline_days` (default: null) - `grace_period_days` (default: null) +#### ios_updates + +- `deadline` specifies the deadline in the form of `YYYY-MM-DD`. The exact deadline time is at 04:00:00 (UTC-8) (default: `""`). +- `minimum_version` specifies the minimum required iOS version (default: `""`). + +#### ipados_updates + +- `deadline` specifies the deadline in the form of `YYYY-MM-DD`. The exact deadline time is at 04:00:00 (UTC-8) (default: `""`). +- `minimum_version` specifies the minimum required iPadOS version (default: `""`). + #### macos_settings and windows_settings - `macos_settings.custom_settings` is a list of paths to macOS configuration profiles (.mobileconfig) or declaration profiles (.json). @@ -273,14 +295,16 @@ Use `labels_include_all` to only apply (scope) profiles to hosts that have all t #### macos_setup -The `macos_setup` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that automatically enrolled to your old MDM solution. +The `macos_setup` section lets you control the out-of-the-box macOS [setup experience](https://fleetdm.com/guides/macos-setup-experience) for hosts that use Automated Device Enrollment (ADE). - `bootstrap_package` is the URL to a bootstap package. Fleet will download the bootstrap package (default: `""`). - `enable_end_user_authentication` specifies whether or not to require end user authentication when the user first sets up their macOS host. -- `macos_setup_assistant` is a path to a custom automatic enrollment (DEP) profile (.json). +- `macos_setup_assistant` is a path to a custom automatic enrollment (ADE) profile (.json). #### macos_migration +The `macos_migration` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that enrolled to your old MDM solution. + - `enable` specifies whether or not to enable end user migration workflow (default: `false`) - `mode` specifies whether the end user initiates migration (`voluntary`) or they're nudged every 15-20 minutes to migrate (`forced`) (default: `""`). - `webhook_url` is the URL that Fleet sends a webhook to when the end user selects **Start**. Receive this webhook using your automation tool (ex. Tines) to unenroll your end users from your old MDM solution. @@ -289,12 +313,18 @@ Can only be configure for all teams (`default.yml`). ### software +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + The `software` section allows you to configure packages and Apple App Store apps that you want to install on your hosts. +Software for hosts that belong to "No team" have to be defined in `teams/no-team.yml`. +Software can also be specified in separate files in your `lib/` folder. - `packages` is a list of software packages (.pkg, .msi, .exe, or .deb) and software specific options. - `app_store_apps` is a list of Apple App Store apps. -##### Example +#### Example + +##### Inline ```yaml software: @@ -309,10 +339,10 @@ software: self_service: true - url: https://github.com/organinzation/repository/package-2.msi app_store_apps: - - app_store_id: 1091189122 + - app_store_id: '1091189122' ``` -#### packages +##### packages - `url` specifies the URL at which the software is located. Fleet will download the software and upload it to S3 (default: `""`). - `install_script.path` specifies the command Fleet will run on hosts to install software. The [default script](https://github.com/fleetdm/fleet/tree/main/pkg/file/scripts) is dependent on the software type (i.e. .pkg). @@ -320,9 +350,40 @@ software: - `post_install_script.path` is the script Fleet will run on hosts after intalling software (default: `""`). - `self_service` specifies whether or not end users can install from **Fleet Desktop > Self-service**. -#### app_store_apps +##### app_store_apps -- `app_store_id` is the ID of the Apple App Store app. You can find this at the end of the app's App Store URL. For example, "Bear - Markdown Notes" URL is "https://apps.apple.com/us/app/bear-markdown-notes/id1016366447" and the `app_store_id` is `1016366447` (default: `0`). +- `app_store_id` is the ID of the Apple App Store app. You can find this at the end of the app's App Store URL. For example, "Bear - Markdown Notes" URL is "https://apps.apple.com/us/app/bear-markdown-notes/id1016366447" and the `app_store_id` is `1016366447`. + +> Make sure to include only the ID itself, and not the `id` prefix shown in the URL. The ID must be wrapped in quotes as shown in the example so that it is processed as a string. + +##### Separate file + +`lib/software-name.package.yml`: + +```yaml +url: https://dl.tailscale.com/stable/tailscale-setup-1.72.0.exe +install_script: + path: ../lib/software/tailscale-install-script.ps1 +self_service: true +``` + +`lib/software/tailscale-install-script.ps1` + +```yaml +$exeFilePath = "${env:INSTALLER_PATH}" +$installProcess = Start-Process $exeFilePath ` + -ArgumentList "/quiet /norestart" ` + -PassThru -Verb RunAs -Wait +``` + +`default.yml`, `teams/team-name.yml`, or `teams/no-team.yml` + +```yaml +software: + packages: + - path: ../lib/software-name.package.yml +# path is relative to default.yml or teams/team-name.yml +``` ### org_settings and team_settings @@ -569,21 +630,74 @@ Can only be configured for all teams (`org_settings`). #### mdm -The `mdm` section lets you enable MDM features in Fleet. +##### apple_business_manager -- `apple_bm_default_team` - is name of the team that macOS hosts in Apple Business Manager automatically enroll to when they're first set up. If empty, hosts will enroll to "No team" (default: `""`). +- `organization_name` is the organization name associated with the Apple Business Manager account. +- `macos_team` is the team where macOS hosts are automatically added when they appear in Apple Business Manager. +- `ios_team` is the the team where iOS hosts are automatically added when they appear in Apple Business Manager. +- `ipados_team` is the team where iPadOS hosts are automatically added when they appear in Apple Business Manager. ##### Example ```yaml org_settings: mdm: - apple_bm_default_team: "Workstations" # Available in Fleet Premium + apple_business_manager: # Available in Fleet Premium + - organization_name: Fleet Device Management Inc. + macos_team: "💻 Workstations" + ios_team: "📱🏢 Company-owned iPhones" + ipados_team: "🔳🏢 Company-owned iPads" +``` + +> Apple Business Manager settings can only be configured for all teams (`org_settings`). + +##### volume_purchasing_program + +- `location` is the name of the location in the Apple Business Manager account. +- `teams` is a list of team names. If you choose specific teams, App Store apps in this VPP account will only be available to install on hosts in these teams. If not specified, App Store apps are available to install on hosts in all teams. + +##### Example + +```yaml +org_settings: + mdm: + volume_purchasing_program: # Available in Fleet Premium + - location: Fleet Device Management Inc. + teams: + - "💻 Workstations" + - "💻🐣 Workstations (canary)" + - "📱🏢 Company-owned iPhones" + - "🔳🏢 Company-owned iPads" ``` Can only be configured for all teams (`org_settings`). - +##### end_user_authentication + +The `end_user_authentication` section lets you define the identity provider (IdP) settings used for end user authentication during Automated Device Enrollment (ADE). Learn more about end user authentication in Fleet [here](https://fleetdm.com/guides/macos-setup-experience#end-user-authentication-and-eula). + +Once the IdP settings are configured, you can use the [`controls.macos_setup.enable_end_user_authentication`](#macos_setup) key to control the end user experience during ADE. + +- `idp_name` is the human-friendly name for the identity provider that will provide single sign-on authentication (default: `""`). +- `entity_id` is the entity ID: a Uniform Resource Identifier (URI) that you use to identify Fleet when configuring the identity provider. It must exactly match the Entity ID field used in identity provider configuration (default: `""`). +- `metadata` is the metadata (in XML format) provided by the identity provider. (default: `""`) +- `metadata_url` is the URL that references the identity provider metadata. Only one of `metadata` or `metadata_url` is required (default: `""`). + +Can only be configured for all teams (`org_settings`). + +##### end_user_authentication + +The `end_user_authentication` section lets you define the identity provider (IdP) settings used for end user authentication during Automated Device Enrollment (ADE). Learn more about end user authentication in Fleet [here](https://fleetdm.com/guides/macos-setup-experience#end-user-authentication-and-eula). + +Once the IdP settings are configured, you can use the [`controls.macos_setup.enable_end_user_authentication`](#macos_setup) key to control the end user experience during ADE. + +- `idp_name` is the human-friendly name for the identity provider that will provide single sign-on authentication (default: `""`). +- `entity_id` is the entity ID: a Uniform Resource Identifier (URI) that you use to identify Fleet when configuring the identity provider. It must exactly match the Entity ID field used in identity provider configuration (default: `""`). +- `metadata` is the metadata (in XML format) provided by the identity provider. (default: `""`) +- `metadata_url` is the URL that references the identity provider metadata. Only one of `metadata` or `metadata_url` is required (default: `""`). + +Can only be configured for all teams (`org_settings`). + + - diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 170ff1c8ea..0b830dbc07 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -11,9 +11,9 @@ - [Scripts](#scripts) - [Software](#software) -This document includes the internal Fleet API routes that are helpful when developing or contributing to Fleet. +> These endpoints are used by the Fleet UI, Fleet Desktop, and `fleetctl` clients and frequently change to reflect current functionality. -These endpoints are used by the Fleet UI, Fleet Desktop, and `fleetctl` clients and will frequently change to reflect current functionality. +This document includes the internal Fleet API routes that are helpful when developing or contributing to Fleet. If you are interested in gathering information from Fleet in a production environment, please see the [public Fleet REST API documentation](https://fleetdm.com/docs/using-fleet/rest-api). @@ -531,15 +531,26 @@ The MDM endpoints exist to support the related command-line interface sub-comman - [Generate Apple Business Manager public key (ADE)](#generate-apple-business-manager-public-key-ade) - [Request Certificate Signing Request (CSR)](#request-certificate-signing-request-csr) - [Upload APNS certificate](#upload-apns-certificate) -- [Upload ABM Token](#upload-abm-token) +- [Add ABM token](#add-abm-token) - [Turn off Apple MDM](#turn-off-apple-mdm) -- [Disable automatic enrollment (ADE)](#disable-automatic-enrollment-ade) +- [Update ABM token's teams](#update-abm-tokens-teams) +- [Renew ABM token](#renew-abm-token) +- [Delete ABM token](#delete-abm-token) +- [Add VPP token](#add-VPP-token) +- [Update VPP token's teams](#update-vpp-tokens-teams) +- [Renew VPP token](#renew-vpp-token) +- [Delete VPP token](#delete-vpp-token) - [Batch-apply MDM custom settings](#batch-apply-mdm-custom-settings) - [Initiate SSO during DEP enrollment](#initiate-sso-during-dep-enrollment) - [Complete SSO during DEP enrollment](#complete-sso-during-dep-enrollment) +- [Over the air enrollment](#over-the-air-enrollment) - [Preassign profiles to devices](#preassign-profiles-to-devices) - [Match preassigned profiles](#match-preassigned-profiles) - [Get FileVault statistics](#get-filevault-statistics) +- [Upload VPP content token](#upload-vpp-content-token) +- [Disable VPP](#disable-vpp) +- [Get an over the air (OTA) enrollment profile](#get-an-over-the-air-ota-enrollment-profile) + ### Generate Apple Business Manager public key (ADE) @@ -617,9 +628,9 @@ Content-Type: application/octet-stream `Status: 200` -### Upload ABM Token +### Add ABM token -`POST /api/v1/fleet/mdm/apple/abm_token` +`POST /api/v1/fleet/abm_tokens` #### Parameters @@ -629,7 +640,7 @@ Content-Type: application/octet-stream #### Example -`POST /api/v1/fleet/mdm/apple/abm_token` +`POST /api/v1/fleet/abm_tokens` ##### Request header @@ -650,11 +661,23 @@ Content-Type: application/octet-stream --------------------------f02md47480und42y ``` - ##### Default response `Status: 200` +```json +"abm_token": { + "id": 1, + "apple_id": "apple@example.com", + "org_name": "Fleet Device Management Inc.", + "mdm_server_url": "https://example.com/mdm/apple/mdm", + "renew_date": "2024-10-20T00:00:00Z", + "terms_expired": false, + "macos_team": null, + "ios_team": null, + "ipados_team": null +} +``` ### Turn off Apple MDM @@ -668,19 +691,265 @@ Content-Type: application/octet-stream `Status: 204` +### Update ABM token's teams -### Disable automatic enrollment (ADE) +`PATCH /api/v1/fleet/abm_tokens/:id/teams` -`DELETE /api/v1/fleet/mdm/apple/abm_token` +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The ABM token's ID | +| macos_team_id | integer | body | macOS hosts are automatically added to this team in Fleet when they appear in Apple Business Manager. If not specified, defaults to "No team" | +| ios_team_id | integer | body | iOS hosts are automatically added to this team in Fleet when they appear in Apple Business Manager. If not specified, defaults to "No team" | +| ipados_team_id | integer | body | iPadOS hosts are automatically added to this team in Fleet when they appear in Apple Business Manager. If not specified, defaults to "No team" | #### Example -`DELETE /api/v1/fleet/mdm/apple/abm_token` +`PATCH /api/v1/fleet/abm_tokens/1/teams` + +##### Request body + +```json +{ + "macos_team_id": 1, + "ios_team_id": 2, + "ipados_team_id": 3 +} +``` + +##### Default response + +`Status: 200` + +```json +"abm_token": { + "id": 1, + "apple_id": "apple@example.com", + "org_name": "Fleet Device Management Inc.", + "mdm_server_url": "https://example.com/mdm/apple/mdm", + "renew_date": "2024-11-29T00:00:00Z", + "terms_expired": false, + "macos_team": 1, + "ios_team": 2, + "ipados_team": 3 +} +``` + +### Renew ABM token + +`PATCH /api/v1/fleet/abm_tokens/:id/renew` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The ABM token's ID | + +#### Example + +`PATCH /api/v1/fleet/abm_tokens/1/renew` + +##### Request header + +```http +Content-Length: 850 +Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y +``` + +##### Request body + +```http +--------------------------f02md47480und42y +Content-Disposition: form-data; name="token"; filename="server_token_abm.p7m" +Content-Type: application/octet-stream + + + +--------------------------f02md47480und42y +``` + +##### Default response + +`Status: 200` + +```json +"abm_token": { + "id": 1, + "apple_id": "apple@example.com", + "org_name": "Fleet Device Management Inc.", + "mdm_server_url": "https://example.com/mdm/apple/mdm", + "renew_date": "2025-10-20T00:00:00Z", + "terms_expired": false, + "macos_team": null, + "ios_team": null, + "ipados_team": null +} +``` + +### Delete ABM token + +`DELETE /api/v1/fleet/abm_tokens/:id` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The ABM token's ID | + +#### Example + +`DELETE /api/v1/fleet/abm_tokens/1` ##### Default response `Status: 204` +### Add VPP token + +`POST /api/v1/fleet/vpp_tokens` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| token | file | form | *Required* The file containing the content token (.vpptoken) from Apple Business Manager | + +#### Example + +`POST /api/v1/fleet/vpp_tokens` + +##### Request header + +```http +Content-Length: 850 +Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y +``` + +##### Request body + +```http +--------------------------f02md47480und42y +Content-Disposition: form-data; name="token"; filename="sToken_for_Acme.vpptoken" +Content-Type: application/octet-stream + +--------------------------f02md47480und42y +``` + +##### Default response + +`Status: 200` + +```json +"vpp_token": { + "id": 1, + "org_name": "Fleet Device Management Inc.", + "location": "https://example.com/mdm/apple/mdm", + "renew_date": "2024-10-20T00:00:00Z", + "terms_expired": false, + "teams": null +} +``` + +### Update VPP token's teams + +`PATCH /api/v1/fleet/vpp_tokens/:id/teams` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The ABM token's ID | +| team_ids | list | body | If you choose specific teams, App Store apps in this VPP account will only be available to install on hosts in these teams. If not specified, defaults to all teams. | + +#### Example + +`PATCH /api/v1/fleet/vpp_tokens/1/teams` + +##### Request body + +```json +{ + "team_ids": [1, 2, 3] +} +``` + +##### Default response + +`Status: 200` + +```json +"vpp_token": { + "id": 1, + "org_name": "Fleet Device Management Inc.", + "location": "https://example.com/mdm/apple/mdm", + "renew_date": "2024-10-20T00:00:00Z", + "terms_expired": false, + "teams": [1, 2, 3] +} +``` + +### Renew VPP token + +`PATCH /api/v1/fleet/vpp_tokens/:id/renew` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The VPP token's ID | + +##### Request header + +```http +Content-Length: 850 +Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y +``` + +##### Request body + +```http +--------------------------f02md47480und42y +Content-Disposition: form-data; name="token"; filename="sToken_for_Acme.vpptoken" +Content-Type: application/octet-stream + + + +--------------------------f02md47480und42y +``` + +##### Default response + +`Status: 200` + +```json +"vpp_token": { + "id": 1, + "org_name": "Fleet Device Management Inc.", + "location": "https://example.com/mdm/apple/mdm", + "renew_date": "2025-10-20T00:00:00Z", + "terms_expired": false, + "teams": [1, 2, 3] +} +``` + +### Delete VPP token + +`DELETE /api/v1/fleet/vpp_token/:id` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | *Required* The VPP token's ID | + +#### Example + +`DELETE /api/v1/fleet/vpp_tokens/1` + +##### Default response + +`Status: 204` ### Batch-apply MDM custom settings @@ -762,6 +1031,34 @@ If the credentials are valid, the server redirects the client to the Fleet UI. T - `profile_token` is a token that can be used to download an enrollment profile (.mobileconfig). - `eula_token` (optional) if an EULA was uploaded, this contains a token that can be used to view the EULA document. +### Over the air enrollment + +This endpoint handles over the air (OTA) MDM enrollments + +`POST /api/v1/fleet/ota_enrollment` + +#### Parameters + +| Name | Type | In | Description | +| ------------------- | ------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| enroll_secret | string | url | **Required** Assigns the host to a team with a matching enroll secret | +| XML device response | XML | body | **Required**. The XML response from the device. Fields are documented [here](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/ConfigurationProfileExamples/ConfigurationProfileExamples.html#//apple_ref/doc/uid/TP40009505-CH4-SW7) | + +> Note: enroll secrets can contain special characters. Ensure any special characters are [properly escaped](https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding). + +#### Example + +`POST /api/v1/fleet/ota_enrollment?enroll_secret=0Z6IuKpKU4y7xl%2BZcrp2gPcMi1kKNs3p` + +##### Default response + +`Status: 200` + +Per [the spec](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009505-CH1-SW1), the response is different depending on the signature of the XML device response: + +- If the body is signed with a certificate that can be validated by our root SCEP certificate, it returns an enrollment profile. +- Otherwise, it returns a SCEP payload. + ### Preassign profiles to devices _Available in Fleet Premium_ @@ -868,6 +1165,55 @@ This endpoint uses the profiles stored by the [Preassign profiles to devices](#p `Status: 204` +### Upload VPP content token + +`POST /api/v1/fleet/mdm/apple/vpp_token` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| token | file | form | *Required* The file containing the content token (.vpptoken) from Apple Business Manager | + +#### Example + +`POST /api/v1/fleet/mdm/apple/vpp_token` + +##### Request header + +```http +Content-Length: 850 +Content-Type: multipart/form-data; boundary=------------------------f02md47480und42y +``` + +##### Request body + +```http +--------------------------f02md47480und42y +Content-Disposition: form-data; name="token"; filename="sToken_for_Acme.vpptoken" +Content-Type: application/octet-stream + +--------------------------f02md47480und42y +``` + +##### Default response + +`Status: 200` + + +### Disable VPP + +`DELETE /api/v1/fleet/mdm/apple/vpp_token` + +#### Example + +`DELETE /api/v1/fleet/mdm/apple/vpp_token` + +##### Default response + +`Status: 204` + + ## Get or apply configuration files These API routes are used by the `fleetctl` CLI tool. Users can manage Fleet with `fleetctl` and [configuration files in YAML syntax](https://fleetdm.com/docs/using-fleet/configuration-files/). @@ -1137,12 +1483,14 @@ NOTE: when updating a policy, team and platform will be ignored. "name": "new policy", "description": "This will be a new policy because a policy with the name 'new policy' doesn't exist in Fleet.", "query": "SELECT * FROM osquery_info", + "team": "No team", "resolution": "some resolution steps here", "critical": false }, { "name": "Is FileVault enabled on macOS devices?", "query": "SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT “” AND filevault_status = ‘on’ LIMIT 1;", + "team": "Workstations", "description": "Checks to make sure that the FileVault feature is enabled on macOS devices.", "resolution": "Choose Apple menu > System Preferences, then click Security & Privacy. Click the FileVault tab. Click the Lock icon, then enter an administrator name and password. Click Turn On FileVault.", "platform": "darwin", @@ -1381,7 +1729,9 @@ If the `name` is not already associated with an existing team, this API route cr | mdm.windows_settings | object | body | The Windows-specific MDM settings. | | mdm.windows_settings.custom_settings | list | body | The list of objects consists of a `path` to XML files and `labels_include_all` or `labels_exclude_any` list of label names. | | scripts | list | body | A list of script files to add to this team so they can be executed at a later time. | -| software | list | body | An array of software objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, `post_install_script` - script that runs after software install, and `self_service` boolean. | +| software | object | body | The team's software that will be available for install. | +| software.packages | list | body | An array of objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, `post_install_script` - script that runs after software install, and `self_service` boolean. | +| software.app_store_apps | list | body | An array objects. Each object consists of `app_store_id` - ID of the App Store app formatted as a string (in quotes) rather than a number. | | mdm.macos_settings.enable_disk_encryption | bool | body | Whether disk encryption should be enabled for hosts that belong to this team. | | force | bool | query | Force apply the spec even if there are (ignorable) validation errors. Those are unknown keys and agent options-related validations. | | dry_run | bool | query | Validate the provided JSON for unknown keys and invalid value types and return any validation errors, but do not apply the changes. | @@ -1462,14 +1812,21 @@ If the `name` is not already associated with an existing team, this API route cr } }, "scripts": ["path/to/script.sh"], - "software": [ - { - "url": "https://cdn.zoom.us/prod/5.16.10.26186/x64/ZoomInstallerFull.msi", - "pre_install_query": "SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db';", - "post_install_script": "sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX", - "self_service": true - } - ] + "software": { + "packages": [ + { + "url": "https://cdn.zoom.us/prod/5.16.10.26186/x64/ZoomInstallerFull.msi", + "pre_install_query": "SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db';", + "post_install_script": "sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX", + "self_service": true, + } + ], + "app_store_apps": [ + { + "app_store_id": "12464567", + } + ] + } } ] } @@ -2481,6 +2838,7 @@ Gets all information required by Fleet Desktop, this includes things like the nu ```json { "failing_policies_count": 3, + "self_service": true, "notifications": { "needs_mdm_migration": true, "renew_enrollment_profile": false, @@ -2540,35 +2898,58 @@ Lists the software installed on the current device. { "id": 121, "name": "Google Chrome.app", + "software_package": { + "name": "GoogleChrome.pkg" + "version": "125.12.2" + "self_service": true, + "last_install": { + "install_uuid": "8bbb8ac2-b254-4387-8cba-4d8a0407368b", + "installed_at": "2024-05-15T15:23:57Z" + }, + }, + "app_store_app": null, "source": "apps", "status": "failed", - "last_install": { - "install_uuid": "8bbb8ac2-b254-4387-8cba-4d8a0407368b", - "installed_at": "2024-05-15T15:23:57Z" - }, "installed_versions": [ - { + { "version": "121.0", "last_opened_at": "2024-04-01T23:03:07Z", "vulnerabilities": ["CVE-2023-1234","CVE-2023-4321","CVE-2023-7654"], "installed_paths": ["/Applications/Google Chrome.app"] } - ] + ], + "software_package": { + "name": "google-chrome-124-0-6367-207.pkg", + "version": "121.0", + "self_service": true, + "icon_url": null, + "last_install": null + }, + "app_store_app": null }, { "id": 143, "name": "Firefox.app", + "software_package": null, + "app_store_app": null, "source": "apps", "status": null, - "last_install": null, "installed_versions": [ - { + { "version": "125.6", "last_opened_at": "2024-04-01T23:03:07Z", "vulnerabilities": ["CVE-2023-1234","CVE-2023-4321","CVE-2023-7654"], "installed_paths": ["/Applications/Firefox.app"] } - ] + ], + "software_package": null, + "app_store_app": { + "app_store_id": "12345", + "version": "125.6", + "self_service": false, + "icon_url": "https://example.com/logo-light.jpg", + "last_install": null + }, } ], "meta": { @@ -2917,7 +3298,7 @@ If the Fleet instance is provided required parameters to complete setup. ## Scripts -### Batch-apply scripts +### Batch-apply scripts _Available in Fleet Premium_ @@ -2946,7 +3327,7 @@ If both `team_id` and `team_name` parameters are included, this endpoint will re ## Software -### Batch-apply software +### Batch-apply software _Available in Fleet Premium._ @@ -2956,10 +3337,12 @@ _Available in Fleet Premium._ | Name | Type | In | Description | | --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. | -| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. | +| team_id | number | query | The ID of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_name`. Ommitting these parameters will add software to 'No Team'. | +| team_name | string | query | The name of the team to add the software package to. Only one team identifier (`team_id` or `team_name`) can be included in the request, omit this parameter if using `team_id`. Ommitting these parameters will add software to 'No Team'. | | dry_run | bool | query | If `true`, will validate the provided software packages and return any validation errors, but will not apply the changes. | -| software | list | body | An array of software objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, and `post_install_script` - script that runs after software install. | +| software | object | body | The team's software that will be available for install. | +| software.packages | list | body | An array of objects. Each object consists of:`url`- URL to the software package (PKG, MSI, EXE or DEB),`install_script` - command that Fleet runs to install software, `pre_install_query` - condition query that determines if the install will proceed, `post_install_script` - script that runs after software install, and `uninstall_script` - command that Fleet runs to uninstall software. | +| software.app_store_apps | list | body | An array objects. Each object consists of `app_store_id` - ID of the App Store app. | If both `team_id` and `team_name` parameters are included, this endpoint will respond with an error. If no `team_name` or `team_id` is provided, the scripts will be applied for **all hosts**. @@ -2969,6 +3352,62 @@ If both `team_id` and `team_name` parameters are included, this endpoint will re ##### Default response +`Status: 200` + +```json +{ + "packages": [ + { + "team_id": 3, + "software_title_id": 6690, + "url": "https://dl.tailscale.com/stable/tailscale-setup-1.72.0.exe" + }, + { + "team_id": 3, + "software_title_id": 10412, + "url": "https://ftp.mozilla.org/pub/firefox/releases/129.0.2/win64/en-US/Firefox%20Setup%20129.0.2.msi" + } + ] +} +``` + +### Batch-apply VPP apps + +_Available in Fleet Premium._ + +`POST /api/latest/fleet/software/app_store_apps/batch` + +#### Parameters + +| Name | Type | In | Description | +| --------- | ------ | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| team_name | string | query | The name of the team to add the software package to. Ommitting this parameter will add software to 'No Team'. | +| dry_run | bool | query | If `true`, will validate the provided VPP apps and return any validation errors, but will not apply the changes. | +| app_store_apps | list | body | An array of objects. Each object contains `app_store_id` and `self_service`. | +| app_store_apps.app_store_id | string | body | ID of the App Store app. | +| app_store_apps.self_service | boolean | body | Whether the VPP app is "Self-service" or not. | + +#### Example + +`POST /api/latest/fleet/software/app_store_apps/batch` +```json +{ + "team_name": "Foobar", + "app_store_apps": { + { + "app_store_id": "597799333", + "self_service": false, + }, + { + "app_store_id": "497799835", + "self_service": true, + } + } +} +``` + +##### Default response + `Status: 204` ### Run live script @@ -3010,3 +3449,130 @@ Run a live script and get results back (5 minute timeout). Live scripts only run "exit_code": 0 } ``` + +### Get token to download package + +_Available in Fleet Premium._ + +`POST /api/v1/fleet/software/titles/:software_title_id/package/token?alt=media` + +The returned token is a one-time use token that expires after 10 minutes. + +#### Parameters + +| Name | Type | In | Description | +|-------------------|---------|-------|------------------------------------------------------------------| +| software_title_id | integer | path | **Required**. The ID of the software title for software package. | +| team_id | integer | query | **Required**. The team ID containing the software package. | +| alt | integer | query | **Required**. Must be specified and set to "media". | + +#### Example + +`POST /api/v1/fleet/software/titles/123/package/token?alt=media&team_id=2` + +##### Default response + +`Status: 200` + +```json +{ + "token": "e905e33e-07fe-4f82-889c-4848ed7eecb7" +} +``` + +### Download package using a token + +_Available in Fleet Premium._ + +`GET /api/v1/fleet/software/titles/:software_title_id/package/token/:token?alt=media` + +#### Parameters + +| Name | Type | In | Description | +|-------------------|---------|------|--------------------------------------------------------------------------| +| software_title_id | integer | path | **Required**. The ID of the software title to download software package. | +| token | string | path | **Required**. The token to download the software package. | + +#### Example + +`GET /api/v1/fleet/software/titles/123/package/token/e905e33e-07fe-4f82-889c-4848ed7eecb7` + +##### Default response + +`Status: 200` + +```http +Status: 200 +Content-Type: application/octet-stream +Content-Disposition: attachment +Content-Length: +Body: +``` + +### Get an over the air (OTA) enrollment profile + +`GET /api/v1/fleet/enrollment_profiles/ota` + +The returned value is a signed `.mobileconfig` OTA profile. + +#### Parameters + +| Name | Type | In | Description | +|-------------------|---------|-------|----------------------------------------------------------------------------------| +| enroll_secret | string | query | **Required**. The enroll secret of the team this host will be assigned to. | + +#### Example + +`GET /api/v1/fleet/enrollment_profiles/ota?enroll_secret=foobar` + +##### Default response + +`Status: 200` + +**Note** To confirm success, it is important for clients to match content length with the response +header (this is done automatically by most clients, including the browser) rather than relying +solely on the response status code returned by this endpoint. + +##### Example response headers + +```http + Content-Length: 542 + Content-Type: application/x-apple-aspen-config; charset=urf-8 + Content-Disposition: attachment;filename="fleet-mdm-enrollment-profile.mobileconfig" + X-Content-Type-Options: nosniff +``` + +###### Example response body + +```xml + + + + + PayloadContent + + URL + https://foo.example.com/api/fleet/ota_enrollment?enroll_secret=foobar + DeviceAttributes + + UDID + VERSION + PRODUCT + SERIAL + + + PayloadOrganization + Acme Inc. + PayloadDisplayName + Acme Inc. enrollment + PayloadVersion + 1 + PayloadUUID + fdb376e5-b5bb-4d8c-829e-e90865f990c9 + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.ota + PayloadType + Profile Service + + +``` diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Contributing/Audit-logs.md similarity index 94% rename from docs/Using Fleet/Audit-logs.md rename to docs/Contributing/Audit-logs.md index 2ddd3bc055..25062d463f 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -1170,6 +1170,29 @@ This activity contains the following fields: } ``` +## uninstalled_software + +Generated when a software is uninstalled on a host. + +This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. +- "software_title": Name of the software. +- "script_execution_id": ID of the software uninstall script. +- "status": Status of the software uninstallation. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", + "software_title": "Falcon.app", + "script_execution_id": "ece8d99d-4313-446a-9af2-e152cd1bad1e", + "status": "uninstalled" +} +``` + ## added_software Generated when a software installer is uploaded to Fleet. @@ -1193,6 +1216,29 @@ This activity contains the following fields: } ``` +## edited_software + +Generated when a software installer is updated in Fleet. + +This activity contains the following fields: +- "software_title": Name of the software. +- "software_package": Filename of the installer as of this update (including if unchanged). +- "team_name": Name of the team on which this software was updated. `null` if it was updated on no team. +- "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team. +- "self_service": Whether the software is available for installation by the end user. + +#### Example + +```json +{ + "software_title": "Falcon.app", + "software_package": "FalconSensor-6.44.pkg", + "team_name": "Workstations", + "team_id": 123, + "self_service": true +} +``` + ## deleted_software Generated when a software installer is deleted from Fleet. @@ -1218,15 +1264,33 @@ This activity contains the following fields: ## enabled_vpp -Generated when the VPP feature is enabled in Fleet. +Generated when VPP features are enabled in Fleet. +This activity contains the following fields: +- "location": Location associated with the VPP content token for the enabled VPP features. +#### Example + +```json +{ + "location": "Acme Inc." +} +``` ## disabled_vpp -Generated when the VPP feature is disabled in Fleet. +Generated when VPP features are disabled in Fleet. +This activity contains the following fields: +- "location": Location associated with the VPP content token for the disabled VPP features. +#### Example + +```json +{ + "location": "Acme Inc." +} +``` ## added_app_store_app @@ -1236,6 +1300,7 @@ This activity contains the following fields: - "software_title": Name of the App Store app. - "app_store_id": ID of the app on the Apple App Store. - "platform": Platform of the app (`darwin`, `ios`, or `ipados`). +- "self_service": App installation can be initiated by device owner. - "team_name": Name of the team to which this App Store app was added, or `null` if it was added to no team. - "team_id": ID of the team to which this App Store app was added, or `null`if it was added to no team. @@ -1246,6 +1311,7 @@ This activity contains the following fields: "software_title": "Logic Pro", "app_store_id": "1234567", "platform": "darwin", + "self_service": false, "team_name": "Workstations", "team_id": 1 } @@ -1280,6 +1346,7 @@ Generated when an App Store app is installed on a device. This activity contains the following fields: - host_id: ID of the host on which the app was installed. +- self_service: App installation was initiated by device owner. - host_display_name: Display name of the host. - software_title: Name of the App Store app. - app_store_id: ID of the app on the Apple App Store. @@ -1290,6 +1357,7 @@ This activity contains the following fields: ```json { "host_id": 42, + "self_service": true, "host_display_name": "Anna's MacBook Pro", "software_title": "Logic Pro", "app_store_id": "1234567", diff --git a/docs/Contributing/File-carving.md b/docs/Contributing/File-carving.md index 4daad97fb0..b283f11f4c 100644 --- a/docs/Contributing/File-carving.md +++ b/docs/Contributing/File-carving.md @@ -77,7 +77,7 @@ The same is not true if S3 is used as the storage backend. In that scenario, it ### Alternative carving backends -#### Minio +#### MinIO Configure the following: - `FLEET_S3_ENDPOINT_URL=minio_host:port` @@ -87,6 +87,11 @@ Configure the following: - `FLEET_S3_FORCE_S3_PATH_STYLE=true` - `FLEET_S3_REGION=minio` or any non-empty string otherwise Fleet will attempt to derive the region. +If you're testing file carving locally with the docker-compose environment, the `--dev` flag on Fleet server will +automatically point carves to the local MinIO container and write to the `carves-dev` bucket without needing to set +additional configuration. Note that this bucket is *not* created automatically when bringing MinIO up; you'll need to +log in via `http://localhost:9001` with credentials `minio` / `minio123!` to create the bucket. + ### Troubleshooting #### Check carve status in osquery diff --git a/docs/Contributing/Simulate-slow-network.md b/docs/Contributing/Simulate-slow-network.md index 3b342a0f3a..6fa02d6f83 100644 --- a/docs/Contributing/Simulate-slow-network.md +++ b/docs/Contributing/Simulate-slow-network.md @@ -7,6 +7,17 @@ The guide assumes you'll build and run the Fleet server locally with the `make f (Has been tested on macOS only.) +## 0. Edit docker-compose.yml + +Add the following service to the main `docker-compose.yml`: +```yml + toxiproxy: + image: shopify/toxiproxy + ports: + - "22220:22220" + - "8474:8474" +``` + ## 1. Start services ```sh @@ -44,4 +55,4 @@ curl -s -XPOST -d '{"type" : "latency", "attributes" : {"latency" : 1000, "jitte ``` - \ No newline at end of file + diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md index a3445e3ccd..45c5a93fe0 100644 --- a/docs/Contributing/Testing-and-local-development.md +++ b/docs/Contributing/Testing-and-local-development.md @@ -81,6 +81,14 @@ REDIS_TEST=1 MYSQL_TEST=1 make test The integration tests in the `server/service` package can generate a lot of logs mixed with the test results output. To make it easier to identify a failing test in this package, you can set the `FLEET_INTEGRATION_TESTS_DISABLE_LOG=1` environment variable so that logging is disabled. +The MDM integration tests are run with a random selection of software installer storage backends (local filesystem or S3/minio), and similar for the bootstrap packages storage (DB or S3/minio). You can force usage of the S3 backend by setting `FLEET_INTEGRATION_TESTS_SOFTWARE_INSTALLER_STORE=s3`. Note that `MINIO_STORAGE_TEST=1` must also be set for the S3 backend to be used. + +When the S3 backend is used, this line will be printed in the tests' output (as this could be relevant to understand and debug the test failure): + +``` + integration_mdm_test.go:196: >>> using S3/minio software installer store +``` + Note that on a Linux system, the Redis tests will include running in cluster mode, so the docker Redis Cluster setup must be running. This implies starting the docker dependencies as follows: ```sh @@ -481,7 +489,9 @@ FLEET_SERVER_SANDBOX_ENABLED=1 FLEET_PACKAGING_GLOBAL_ENROLL_SECRET=xyz ./build Be sure to replace the `FLEET_PACKAGING_GLOBAL_ENROLL_SECRET` value above with the global enroll secret from the `fleetctl package` command used to build the installers. -MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. +MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. When starting the +Fleet server up with `--dev` the server will look for installers in the `software-installers-dev` MinIO bucket. You can +create this bucket via the MinIO web UI (it is *not* created by default when setting up the docker-compose environment). ## Telemetry diff --git a/docs/Using Fleet/Understanding-host-vitals.md b/docs/Contributing/Understanding-host-vitals.md similarity index 100% rename from docs/Using Fleet/Understanding-host-vitals.md rename to docs/Contributing/Understanding-host-vitals.md diff --git a/docs/Contributing/Vulnerability-processing.md b/docs/Contributing/Vulnerability-processing.md index 6f7db26328..7a159bc000 100644 --- a/docs/Contributing/Vulnerability-processing.md +++ b/docs/Contributing/Vulnerability-processing.md @@ -35,7 +35,7 @@ be able write a query to retrieve all CVEs). - Fleet combines two sources to get accurate and up-to-date CVE information: - [National Vulnerability Database](https://nvd.nist.gov/developers/vulnerabilities)'s CVE feeds. - [VulnCheck](https://vulncheck.com/) -- To reduce the load and complexity of processing these datasets, Fleet uses two Github repositories (https://github.com/fleetdm/nvd and https://github.com/fleetdm/vulndb) that fetch, pre-process and expose the resulting dataset as Github releases. +- To reduce the load and complexity of processing these datasets, Fleet uses two Github repositories (https://github.com/fleetdm/nvd and https://github.com/fleetdm/vulnerabilities) that fetch, pre-process and expose the resulting dataset as Github releases. - The Fleet servers then download these Github releases and run vulnerability processing using the downloaded datasets and the software information fetched from hosts. ```mermaid @@ -78,16 +78,17 @@ information and what vulnerabilities were patched with the release, we then exam macOS apps and if an Office app is found we compare its version with the release notes metadata and report back any vulnerabilities to which the software is susceptible. -### Linux hosts +### Linux hosts (via OVAL) First, we determine what Linux distributions are part of your fleet (keep in mind that there will -be a small delay between the time a new Linux hosts is added and the time the host is 'detected'). We then -use -that information to determine what OVAL definitions need to be downloaded and parsed - you can find -a list of all the OVAL definitions we use -[here](https://github.com/fleetdm/nvd/blob/master/oval_sources.json). OVAL definitions will be +be a small delay between the time a new Linux host is added and the time the host is "detected"). We then +use that information to determine what OVAL definitions need to be downloaded and parsed - you can find +a list of all the OVAL definitions we use [here](https://github.com/fleetdm/nvd/blob/master/oval_sources.json). OVAL definitions will be refreshed on a daily basis. +*NOTE:* Amazon Linux 2 is included in the OVAL mapping but vulnerabilities are no longer pulled via that file +as of 4.56.0 due to false positives (Amazon backports fixes and releases updates independent of RHEL). + Finally, we look at the software inventory of each host and execute the assertions contained in the corresponding OVAL file - any match is reported using the same channels as with Windows/Mac OS vulnerabilities @@ -104,6 +105,20 @@ available in the OVAL feed, it uses the NVD feed to look for vulnerabilities mat CPE pattern: `cpe:2.3:o:linux:linux_kernel:*;*:*:*:*:*:*:*:*` +### Amazon Linux hosts (via goval-dictionary) + +Amazon Linux uses [ALAS](https://alas.aws.amazon.com/) rather than OVAL files to provide vulnerability info. The +[goval-dictionary](https://github.com/vulsio/goval-dictionary) tool supports fetching those bulletins into database +formats including sqlite. The database contains mappings of CVEs to package releases where that CVE was fixed. We run +this tool for each Amazon Linux version as part of the release process in https://github.com/fleetdm/vulnerabilities. + +As part of the (default hourly) vulnerabilities check run, we download the sqlite files for relevant OS versions (keep in +mind that there will be a small delay between the time a new Linux host is added and the time the host is "detected"), +then for each host check if each package on that host is mentioned in the database. For each package found, we report +a vulnerability when the installed version of the package is older than the version where the vulnerability was fixed. + +ALAS does *not* use CPE lookups. + ## Performance ### Windows/Mac OS @@ -127,9 +142,9 @@ For example, when running a development instance of Fleet on an Apple Macbook Pr The CPU and memory usages are in burst once every hour (or the configured periodicity) on the instance that does the processing. RAM spikes are expected to not exceed the 2GBs. -### Linux +### Linux (OVAL) -As with Windows/Mac OS, vulnerability detection for Linux is performed in a single Fleet instance. The +As with Windows/Mac OS, vulnerability detection for Linux is performed on a single Fleet server. The files downloaded will vary depending on what distributions are on your fleet. The list of all the OVAL files we use can be found [here](https://github.com/fleetdm/nvd/blob/master/oval_sources.json). @@ -151,6 +166,19 @@ The performance will be a function of three variables: That said, the performance characteristic should be linear (if scanning 200 hosts take ~20 seconds, then scanning 2000 hosts should take ~200 seconds). +### Linux (goval-dictionary) + +As with OVAL, vulnerability detection for Linux via goval-dictionary is performed on a single Fleet server. +Fleet will download one xz'd sqlite file per supported (see oval_platform.go -> IsGovalDictionarySupported) OS/version +combination every time vulnerability scanning is run. OS version identifiers are constructed the same way +as for OVAL, so Amazon Linux 2 becomes `amzn_02` and Amazon Linux 2023 becomes `amzn_2023`. sqlite databases are +preprocessed as part of our vulnerabilities feed build, so are used as-is after download/extraction; the largest +database is currently for Amazon Linux 2, at ~10 MiB. Filename format on disk is +`fleet_goval_dictionary_platform.sqlite3`, corresponding to `platform.sqlite3` in the `vulnerabilities` repo releases. + +Like OVAL, execution time currently scales with the size of the database, the number of hosts to scan, and the number +of packages installed. Scanning 2000 hosts will take about 10x longer than scanning 200 hosts. + ## Detection pipeline There are several steps that go into the vulnerability detection process. In this section we'll dive into what they are and how it works. @@ -184,7 +212,7 @@ The whole pipeline exists to compensate for these differences, and it can be div process2-->interval ``` - ### Windows/Mac OS +### Windows/Mac OS ```mermaid graph TD; @@ -194,7 +222,7 @@ The whole pipeline exists to compensate for these differences, and it can be div cveDownload-->cveMap[CVE detection] ``` - ### Linux +### Linux (OVAL) ```mermaid graph TD; @@ -206,6 +234,14 @@ The whole pipeline exists to compensate for these differences, and it can be div parse --> execute ``` +### Linux (goval-dictionary) + + ```mermaid + graph TD; + process[Process Linux hosts] --> download(Download vulnerability databases from Fleet) + download --> execute(Match vulnerabilities to software older than fixed versions) + ``` + ### Ingesting software lists from hosts The ingestion of software varies per platform. We run a `UNION` of several queries in each: diff --git a/docs/Contributing/fleetctl-apply.md b/docs/Contributing/fleetctl-apply.md index a502a1f516..dac4b25630 100644 --- a/docs/Contributing/fleetctl-apply.md +++ b/docs/Contributing/fleetctl-apply.md @@ -338,7 +338,8 @@ List of saved scripts that can be run on hosts that are part of the team. - Default value: none - Config file format: - ```yaml + +```yaml apiVersion: v1 kind: team spec: @@ -347,7 +348,7 @@ spec: scripts: - path/to/script1.sh - path/to/script2.sh - ``` +``` ## Organization settings diff --git a/docs/Deploy/Reference-Architectures.md b/docs/Deploy/Reference-Architectures.md index 06a7d8dd5a..eecc179c36 100644 --- a/docs/Deploy/Reference-Architectures.md +++ b/docs/Deploy/Reference-Architectures.md @@ -2,10 +2,10 @@ ## The Fleet binary -The Fleet application contains two single static binaries which provide web based administration, REST API, and CLI interface to Fleet. +The Fleet application contains two single static binaries which provide web based administration, a REST API, and a [CLI interface](https://fleetdm.com/guides/fleetctl). The `fleet` binary contains: -- The Fleet TLS web server (no external webserver is required but it supports a proxy if desired) +- The [Fleet TLS web server](https://fleetdm.com/docs/configuration/fleet-server-configuration) (no external webserver is required but it supports a proxy if desired) - The Fleet web interface - The Fleet application management [REST API](https://fleetdm.com/docs/using-fleet/rest-api) - The Fleet osquery API endpoints @@ -24,15 +24,15 @@ Fleet currently has three infrastructure dependencies: MySQL, Redis, and a TLS c ### MySQL Fleet uses MySQL extensively as its main database. Many cloud providers (such as [AWS](https://aws.amazon.com/rds/mysql/) and [GCP](https://cloud.google.com/sql/)) host reliable MySQL services which you may consider for this purpose. A well-supported MySQL [Docker image](https://hub.docker.com/_/mysql/) also exists if you would rather run MySQL in a container. -For more information on how to configure the `fleet` binary to use the correct MySQL instance, see the [Configuration](https://fleetdm.com/docs/deploying/configuration) document. +For more information on how to configure the `fleet` binary to use the correct MySQL instance, see the [MySQL configuration](https://fleetdm.com/docs/configuration/fleet-server-configuration#mysql) documentation. -Fleet requires at least MySQL version 8.0, and is tested using the InnoDB storage engine. +Fleet requires at least MySQL version 8.0.36, and is tested using the InnoDB storage engine [with versions 8.0.36 and 8.4.2](https://github.com/fleetdm/fleet/blob/main/.github/workflows/test-go.yaml#L47). There are many "drop-in replacements" for MySQL available. If you'd like to experiment with some bleeding-edge technology and use Fleet with one of these alternative database servers, we think that's awesome! Please be aware they are not officially supported and that it is very important to set up a dev environment to thoroughly test new releases. ### Redis -Fleet uses Redis to ingest and queue the results of distributed queries, cache data, etc. Many cloud providers (such as [AWS](https://aws.amazon.com/elasticache/) and [GCP](https://console.cloud.google.com/launcher/details/click-to-deploy-images/redis)) host reliable Redis services which you may consider for this purpose. A well supported Redis [Docker image](https://hub.docker.com/_/redis/) also exists if you would rather run Redis in a container. For more information on how to configure the `fleet` binary to use the correct Redis instance, see the [Configuration](https://fleetdm.com/docs/deploying/configuration) document. +Fleet uses Redis to ingest and queue the results of distributed queries, cache data, etc. Many cloud providers (such as [AWS](https://aws.amazon.com/elasticache/) and [GCP](https://console.cloud.google.com/launcher/details/click-to-deploy-images/redis)) host reliable Redis services which you may consider for this purpose. A well supported Redis [Docker image](https://hub.docker.com/_/redis/) also exists if you would rather run Redis in a container. For more information on how to configure the `fleet` binary to use the correct Redis instance, see the [Redis configuration](https://fleetdm.com/docs/configuration/fleet-server-configuration#redis) documentation. ## Systemd @@ -150,7 +150,7 @@ In some cases adding a read replica can increase database performance for specif #### Traffic load balancing Load balancing enables distributing request traffic over many instances of the backend application. Using AWS Application -Load Balancer can also [offload SSL termination](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html), freeing Fleet to spend the majority of it's allocated compute dedicated +Load Balancer can also [offload SSL termination](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html), freeing Fleet to spend the majority of its allocated compute dedicated to its core functionality. More details about ALB can be found [here](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). _**Note if using [terraform reference architecture](https://github.com/fleetdm/fleet/tree/main/infrastructure/dogfood/terraform/aws#terraform) all configurations can dynamically scale based on load(cpu/memory) and all configurations @@ -167,10 +167,11 @@ assume On-Demand pricing (savings are available through Reserved Instances). Cal | --------------- | ------------- | --- | | 1 Fargate task | 512 CPU Units | 4GB | -| Dependencies | Version | Instance type | Nodes | -| ------------ | ----------------------- | ------------- | ----- | -| Redis | 6 | t4g.small | 3 | -| MySQL | 8.0.mysql_aurora.3.04.2 | db.t4g.medium | 2 | +| Dependencies | Version | Instance type | Nodes | +| ------------ | ----------------------- | --------------- | ----- | +| Redis | 6 | cache.t4g.small | 3 | +| MySQL | 8.0.mysql_aurora.3.04.2 | db.t4g.medium | 2 | + ###### [Up to 25000 hosts](https://calculator.aws/#/estimate?id=d735758715f059118dbce8dc42f3ff2410adc621) @@ -178,10 +179,10 @@ assume On-Demand pricing (savings are available through Reserved Instances). Cal | --------------- | -------------- | --- | | 10 Fargate task | 1024 CPU Units | 4GB | -| Dependencies | Version | Instance type | Nodes | -| ------------ | ----------------------- | ------------- | ----- | -| Redis | 6 | m6g.large | 3 | -| MySQL | 8.0.mysql_aurora.3.04.2 | db.r6g.large | 2 | +| Dependencies | Version | Instance type | Nodes | +| ------------ | ----------------------- | --------------- | ----- | +| Redis | 6 | cache.m6g.large | 3 | +| MySQL | 8.0.mysql_aurora.3.04.2 | db.r6g.large | 2 | ###### [Up to 150000 hosts](https://calculator.aws/#/estimate?id=689fea65efff361ee070b15044a01224b8d26621) @@ -190,10 +191,11 @@ assume On-Demand pricing (savings are available through Reserved Instances). Cal | --------------- | -------------- | --- | | 20 Fargate task | 1024 CPU Units | 4GB | -| Dependencies | Version | Instance type | Nodes | -| ------------ | ----------------------- | -------------- | ----- | -| Redis | 6 | m6g.large | 3 | -| MySQL | 8.0.mysql_aurora.3.04.2 | db.r6g.4xlarge | 2 | +| Dependencies | Version | Instance type | Nodes | +| ------------ | ----------------------- | --------------- | ----- | +| Redis | 6 | cache.m6g.large | 3 | +| MySQL | 8.0.mysql_aurora.3.04.2 | db.r6g.4xlarge | 2 | + ###### [Up to 300000 hosts](https://calculator.aws/#/estimate?id=19b667fde567df0d64d9fae632d4885d7fdc726a) @@ -203,7 +205,7 @@ assume On-Demand pricing (savings are available through Reserved Instances). Cal | Dependencies | Version | Instance type | Nodes | | ------------ | ----------------------- | --------------- | ----- | -| Redis | 6 | m6g.large | 3 | +| Redis | 6 | cache.m6g.large | 3 | | MySQL | 8.0.mysql_aurora.3.04.2 | db.r6g.16xlarge | 2 | AWS reference architecture can be found [here](https://github.com/fleetdm/fleet/tree/main/terraform/example). This configuration includes: diff --git a/docs/Deploy/deploy-fleet.md b/docs/Deploy/deploy-fleet.md index c0e80f4b2e..5f1355b80b 100644 --- a/docs/Deploy/deploy-fleet.md +++ b/docs/Deploy/deploy-fleet.md @@ -43,11 +43,11 @@ Render is a cloud hosting service that makes it easy to get up and running fast, -1. Click "Deploy to Render" to open the Fleet Blueprint on Render. You will be prompted to create or log in to your Render account with associated payment information. +1. Click "Deploy to Render" to open the Fleet Blueprint on Render. Ensure that the Redis instance is manually set to the same region as your other resources. You will be prompted to create or log in to your Render account with associated payment information. 2. Give the Blueprint a unique name like `yourcompany-fleet`. -3. Click "**Apply.**" Render will provision your services, which should take less than five minutes. +3. Click "**Deploy Blueprint.**" Render will provision your services, which should take less than five minutes. 4. Click the "**Dashboard**" tab in Render when provisioning is complete to see your new services. diff --git a/docs/Get started/FAQ.md b/docs/Get started/FAQ.md index fdcd1ab596..6e4fb060f4 100644 --- a/docs/Get started/FAQ.md +++ b/docs/Get started/FAQ.md @@ -45,6 +45,60 @@ When you collect data with Fleet, the [performance impact](https://fleetdm.com/r You can test changes on a small subset of hosts first, then roll them out to the rest of your organization. +## What browsers does Fleet supported? + +Fleet supports the latest, stable releases of all major browsers and platforms. + +We test each browser on Windows whenever possible, because our engineering team primarily uses macOS. + +**Note:** This information also applies to [fleetdm.com](https://www.fleetdm.com). + +### Desktop + +- Chrome +- Firefox +- Edge +- Safari (macOS only) + +### Mobile + +- Mobile Safari on iOS +- Mobile Chrome on Android + +### Note +> - Mobile web is not yet supported in the Fleet product. +> - The Fleet user interface [may not be fully supported](https://github.com/fleetdm/fleet/issues/969) in Google Chrome when the browser is running on ChromeOS. + +## What host operating systems does Fleet support? + +Fleet supports the following operating system versions on hosts. + +| OS | Supported version(s) | +| :------ | :------------------------------------- | +| macOS | 13+ (Ventura) | +| Windows | Pro and Enterprise 10+, Server 2012+ | +| Linux | CentOS 7.1+, Ubuntu 20.04+, Fedora 38+ | +| ChromeOS | 112.0.5615.134+ | + +While Fleet may still function partially or fully with OS versions older than those above, Fleet does not actively test against unsupported versions and does not pursue bugs on them. + +## Some notes on compatibility + +### Tables +Not all osquery tables are available for every OS. Please check out the [osquery schema](https://fleetdm.com/tables) for detailed information. + +If a table is not available for your host, Fleet will generally handle things behind the scenes for you. + +### Linux + +Fleet Desktop is supported on Ubuntu and Fedora. + +Fedora requires a [gnome extension](https://extensions.gnome.org/extension/615/appindicator-support/) and Google Chrome for Fleet Desktop. + +On Ubuntu, Fleet Desktop currently supports Xorg as X11 server, Wayland is currently not supported. Ubuntu 24.04 comes with Wayland enabled by default. To use X11 instead of Wayland you can set `WaylandEnable=false` in `/etc/gdm3/custom.conf` and reboot. + +The `fleetctl package` command is not supported on DISA-STIG distribution. + ## Is Fleet MIT licensed? Different portions of the Fleet software are licensed differently, as noted in the [LICENSE](https://github.com/fleetdm/fleet/blob/main/LICENSE) file. The majority of Fleet is MIT licensed. Paid features require a license key. @@ -71,7 +125,7 @@ Different portions of the Fleet software are licensed differently, as noted in t ## How do I contact Fleet for support? -A lot of questions can be answered [in the documentation](https://fleetdm.com/docs). +A lot of questions can be answered [in the documentation](https://fleetdm.com/docs) or [guides](https://fleetdm.com/guides). To get help from the community, visit https://fleetdm.com/support. @@ -614,7 +668,7 @@ Yes! Please sign up for the [Fleet Cloud Beta](https://kqphpqst851.typeform.com/ ### What MySQL versions are supported? -Fleet is tested with MySQL 8.0.36. Newer versions of MySQL 8 typically work well. AWS Aurora requires at least version 2.10.0. Please avoid using MariaDB or other MySQL variants that are not officially supported. Compatibility issues have been identified with MySQL variants, and these may not be addressed in future Fleet releases. +Fleet is tested with MySQL 8.0.36 and 8.4.2. Newer versions of MySQL 8 typically work well. AWS Aurora requires at least version 3.07.0. Please avoid using MariaDB or other MySQL variants that are not officially supported. Compatibility issues have been identified with MySQL variants, and these may not be addressed in future Fleet releases. ### What are the MySQL user requirements? @@ -677,7 +731,7 @@ If you would like to use Fleet's MDM features, the following endpoints need to b ### What is the minimum version of MySQL required by Fleet? -Fleet requires at least MySQL version 8.0. +Fleet requires at least MySQL version 8.0.36, and is tested [with versions 8.0.36 and 8.4.2](https://github.com/fleetdm/fleet/blob/main/.github/workflows/test-go.yaml#L47) ### How do I migrate from Fleet Free to Fleet Premium? diff --git a/docs/Get started/tutorials-and-guides.md b/docs/Get started/tutorials-and-guides.md index 0b1584f6a3..f83de79f27 100644 --- a/docs/Get started/tutorials-and-guides.md +++ b/docs/Get started/tutorials-and-guides.md @@ -1,71 +1,29 @@ # Tutorials and guides -A collection of guides to help you get up and running with Fleet. +A collection of guides to help you with Fleet. -## Deployment guides - -- [Deploy Fleet on Render](http://fleetdm.com/deploy/deploying-fleet-on-render) - -- [Deploy Fleet on AWS](http://fleetdm.com/deploy/deploying-fleet-on-aws-with-terraform) - -- [Deploy Fleet on Hetzner Cloud](http://fleetdm.com/deploy/deploy-fleet-on-hetzner-cloud) - -- [Deploy Fleet on AWS ECS](https://fleetdm.com/docs/deploy/deploy-fleet-on-aws-ecs) - -- [Deploy Fleet on CentOS](https://fleetdm.com/docs/deploy/deploy-fleet-on-centos) - -- [Deploy Fleet on Cloud.gov](https://fleetdm.com/docs/deploy/cloudgov) - -- [Deploy Fleet on Kubernetes](https://fleetdm.com/docs/deploy/deploy-fleet-on-kubernetes) - -## How-to guides - -- [Querying process_file_events on CentOS 7](https://fleetdm.com/guides/querying-process-file-events-table-on-centos-7) - -- [Using GitHub Actions to apply configuration profiles with Fleet](https://fleetdm.com/guides/using-github-actions-to-apply-configuration-profiles-with-fleet) - -- [Building an effective dashboard with Fleet's REST API, Flask, and Plotly: A step-by-step guide](https://fleetdm.com/guides/building-an-effective-dashboard-with-fleet-rest-api-flask-and-plotly) - -- [Discovering Geacon using Fleet](https://fleetdm.com/guides/discovering-geacon-using-fleet) - -- [Using Fleet and Okta Workflows to generate a daily OS report](https://fleetdm.com/guides/using-fleet-and-okta-workflows-to-generate-a-daily-os-report) - -- [Using Fleet and Tines together](https://fleetdm.com/guides/using-fleet-and-tines-together) - -- [How to use Fleet for zero trust attestation](https://fleetdm.com/guides/zero-trust-attestation-with-fleet) - -- [How to use osquery evented tables](https://fleetdm.com/guides/osquery-evented-tables-overview) - -- [Enrolling a DigitalOcean Droplet on a Fleet instance](https://fleetdm.com/guides/enrolling-a-digital-ocean-droplet-on-a-fleet-instance) - -- [Osquery: a tool to easily ask questions about operating systems](https://fleetdm.com/guides/osquery-a-tool-to-easily-ask-questions-about-operating-systems) - -- [How to install osquery and enroll Linux devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-linux-devices-into-fleet) - -- [How to install osquery and enroll Windows devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-windows-devices-into-fleet) - -- [Delivering data to Snowflake from Fleet and osquery.](https://fleetdm.com/guides/delivering-data-to-snowflake-from-fleet-and-osquery) - -- [How to install osquery and enroll macOS devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-macos-devices-into-fleet) - -- [How to uninstall osquery](https://fleetdm.com/guides/how-to-uninstall-osquery) - -- [Converting unix timestamps with osquery](https://fleetdm.com/guides/converting-unix-timestamps-with-osquery) - -- [Correlate network connections with community ID in osquery.](https://fleetdm.com/guides/correlate-network-connections-with-community-id-in-osquery) - -- [Using Elasticsearch and Kibana to visualize osquery performance](https://fleetdm.com/guides/using-elasticsearch-and-kibana-to-visualize-osquery-performance) - -- [Fleet quick tips — identify systems where the ProcDump EULA has been accepted](https://fleetdm.com/guides/fleet-quick-tips-querying-procdump-eula-has-been-accepted) - -- [Locate device assets in the event of an emergency.](https://fleetdm.com/guides/locate-assets-with-osquery) - -- [Osquery: Consider joining against the users table](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) - -- [Import and export queries in Fleet](https://fleetdm.com/guides/import-and-export-queries-in-fleet) - -- [Generate process trees with osquery](https://fleetdm.com/guides/generate-process-trees-with-osquery) +- [How to install osquery and enroll macOS devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-macos-devices-into-fleet) +- [How to install osquery and enroll Linux devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-linux-devices-into-fleet) +- [How to install osquery and enroll Windows devices into Fleet](https://fleetdm.com/guides/how-to-install-osquery-and-enroll-windows-devices-into-fleet) +- [Sysadmin diaries: restoring fleetd](https://fleetdm.com/guides/sysadmin-diaries-restoring-fleetd) +- [How to uninstall osquery](https://fleetdm.com/guides/how-to-uninstall-osquery) +- [Sysadmin diaries: device enrollment](https://fleetdm.com/guides/sysadmin-diaries-device-enrollment) +- [Sysadmin diaries: passcode profiles](https://fleetdm.com/guides/sysadmin-diaries-passcode-profiles) +- [Sysadmin diaries: lost device](https://fleetdm.com/guides/sysadmin-diaries-lost-device) +- [Windows MDM setup](https://fleetdm.com/guides/windows-mdm-setup) +- [Using GitHub Actions to apply configuration profiles with Fleet](https://fleetdm.com/guides/using-github-actions-to-apply-configuration-profiles-with-fleet) +- [Managing labels in Fleet](https://fleetdm.com/guides/managing-labels-in-fleet) +- [What are Fleet policies?](https://fleetdm.com/securing/what-are-fleet-policies) +- [Understanding the intricacies of Fleet policies](https://fleetdm.com/guides/understanding-the-intricacies-of-fleet-policies) +- [Sysadmin diaries: exporting policies](https://fleetdm.com/guides/sysadmin-diaries-exporting-policies) +- [Locate device assets in the event of an emergency](https://fleetdm.com/guides/locate-assets-with-osquery) +- [Osquery: Consider joining against the users table](https://fleetdm.com/guides/osquery-consider-joining-against-the-users-table) +- [Using Fleet and Okta Workflows to generate a daily OS report](https://fleetdm.com/guides/using-fleet-and-okta-workflows-to-generate-a-daily-os-report) +- [How to configure logging destinations](https://fleetdm.com/guides/how-to-configure-logging-destinations) +- [Import and export queries in Fleet](https://fleetdm.com/guides/import-and-export-queries-in-fleet) +- [Certificates in fleetd](https://fleetdm.com/guides/certificates-in-fleetd) +See all guides diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index e07c86e33f..d7b8b8f172 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -484,6 +484,22 @@ for pagination. For a comprehensive list of activity types and detailed informat ```json { "activities": [ + { + "created_at": "2023-07-27T14:35:08Z", + "id": 25, + "actor_full_name": "Anna Chao", + "actor_id": 3, + "actor_gravatar": "", + "actor_email": "", + "type": "uninstalled_software", + "details": { + "host_id": 1, + "host_display_name": "Marko's MacBook Pro", + "software_title": "Adobe Acrobat.app", + "script_execution_id": "eeeddb94-52d3-4071-8b18-7322cd382abb", + "status": "failed" + } + }, { "created_at": "2021-07-30T13:41:07Z", "id": 24, @@ -878,15 +894,20 @@ None. "additional_queries": null }, "mdm": { - "apple_bm_default_team": "", - "apple_bm_terms_expired": false, - "enabled_and_configured": true, "windows_enabled_and_configured": true, "enable_disk_encryption": true, "macos_updates": { "minimum_version": "12.3.1", "deadline": "2022-01-01" }, + "ios_updates": { + "minimum_version": "17.0.1", + "deadline": "2024-08-01" + }, + "ipados_updates": { + "minimum_version": "17.0.1", + "deadline": "2024-08-01" + }, "windows_updates": { "deadline_days": 5, "grace_period_days": 1 @@ -1068,23 +1089,23 @@ Modifies the Fleet's configuration with the supplied information. #### Parameters -| Name | Type | In | Description | -| --------------------- | ------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| org_info | object | body | See [org_info](#org-info). | -| server_settings | object | body | See [server_settings](#server-settings). | -| smtp_settings | object | body | See [smtp_settings](#smtp-settings). | -| sso_settings | object | body | See [sso_settings](#sso-settings). | -| host_expiry_settings | object | body | See [host_expiry_settings](#host-expiry-settings). | -| activity_expiry_settings | object | body | See [activity_expiry_settings](#activity-expiry-settings). | -| agent_options | objects | body | The agent_options spec that is applied to all hosts. In Fleet 4.0.0 the `api/v1/fleet/spec/osquery_options` endpoints were removed. | -| fleet_desktop | object | body | See [fleet_desktop](#fleet-desktop). | -| webhook_settings | object | body | See [webhook_settings](#webhook-settings). | -| integrations | object | body | Includes `jira`, `zendesk`, and `google_calendar` arrays. See [integrations](#integrations) for details. | -| mdm | object | body | See [mdm](#mdm). | -| features | object | body | See [features](#features). | -| scripts | list | body | A list of script files to add so they can be executed at a later time. | -| force | boolean | query | Whether to force-apply the agent options even if there are validation errors. | -| dry_run | boolean | query | Whether to validate the configuration and return any validation errors **without** applying changes. | +| Name | Type | In | Description | +| ----------------------- | ------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------ | +| org_info | object | body | See [org_info](#org-info). | +| server_settings | object | body | See [server_settings](#server-settings). | +| smtp_settings | object | body | See [smtp_settings](#smtp-settings). | +| sso_settings | object | body | See [sso_settings](#sso-settings). | +| host_expiry_settings | object | body | See [host_expiry_settings](#host-expiry-settings). | +| activity_expiry_settings | object | body | See [activity_expiry_settings](#activity-expiry-settings). | +| agent_options | objects | body | The agent_options spec that is applied to all hosts. In Fleet 4.0.0 the `api/v1/fleet/spec/osquery_options` endpoints were removed. | +| fleet_desktop | object | body | See [fleet_desktop](#fleet-desktop). | +| webhook_settings | object | body | See [webhook_settings](#webhook-settings). | +| integrations | object | body | Includes `jira`, `zendesk`, and `google_calendar` arrays. See [integrations](#integrations) for details. | +| mdm | object | body | See [mdm](#mdm). | +| features | object | body | See [features](#features). | +| scripts | array | body | A list of script files to add so they can be executed at a later time. | +| force | boolean | query | Whether to force-apply the agent options even if there are validation errors. | +| dry_run | boolean | query | Whether to validate the configuration and return any validation errors **without** applying changes. | #### Example @@ -1162,9 +1183,6 @@ Modifies the Fleet's configuration with the supplied information. "expiration": "0001-01-01T00:00:00Z" }, "mdm": { - "apple_bm_default_team": "", - "apple_bm_terms_expired": false, - "apple_bm_enabled_and_configured": false, "enabled_and_configured": false, "windows_enabled_and_configured": false, "enable_disk_encryption": true, @@ -1172,6 +1190,14 @@ Modifies the Fleet's configuration with the supplied information. "minimum_version": "12.3.1", "deadline": "2022-01-01" }, + "ios_updates": { + "minimum_version": "17.0.1", + "deadline": "2024-08-01" + }, + "ipados_updates": { + "minimum_version": "17.0.1", + "deadline": "2024-08-01" + }, "windows_updates": { "deadline_days": 5, "grace_period_days": 1 @@ -1310,11 +1336,11 @@ Modifies the Fleet's configuration with the supplied information. #### org_info | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| org_name | string | The organization name. | -| org_logo_url | string | The URL for the organization logo. | -| org_logo_url_light_background | string | The URL for the organization logo displayed in Fleet on top of light backgrounds. | -| contact_url | string | A URL that can be used by end users to contact the organization. | +| --------------------- | ------- | ----------------------------------------------------------------------------------- | +| org_name | string | The organization name. | +| org_logo_url | string | The URL for the organization logo. | +| org_logo_url_light_background | string | The URL for the organization logo displayed in Fleet on top of light backgrounds. | +| contact_url | string | A URL that can be used by end users to contact the organization. |
@@ -1334,12 +1360,12 @@ Modifies the Fleet's configuration with the supplied information. #### server_settings | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| server_url | string | The Fleet server URL. | -| enable_analytics | boolean | Whether to send anonymous usage statistics. Always enabled for Fleet Premium customers. | -| live_query_disabled | boolean | Whether the live query capabilities are disabled. | -| query_reports_disabled | boolean | Whether query report capabilities are disabled. | -| ai_features_disabled | boolean | Whether AI features are disabled. | +| --------------------- | ------- | ------------------------------------------------------------------------------------------- | +| server_url | string | The Fleet server URL. | +| enable_analytics | boolean | Whether to send anonymous usage statistics. Always enabled for Fleet Premium customers. | +| live_query_disabled | boolean | Whether the live query capabilities are disabled. | +| query_reports_disabled | boolean | Whether query report capabilities are disabled. | +| ai_features_disabled | boolean | Whether AI features are disabled. | | query_report_cap | integer | The maximum number of results to store per query report before the report is clipped. If increasing this cap, we recommend enabling reports for one query at time and monitoring your infrastructure. (Default: `1000`) |
@@ -1361,7 +1387,7 @@ Modifies the Fleet's configuration with the supplied information. #### smtp_settings | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | enable_smtp | boolean | Whether SMTP is enabled for the Fleet app. | | sender_address | string | The sender email address for the Fleet app. An invitation email is an example of the emails that may use this sender address | | server | string | The SMTP server for the Fleet app. | @@ -1401,13 +1427,13 @@ Modifies the Fleet's configuration with the supplied information. #### sso_settings | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | enable_sso | boolean | Whether or not SSO is enabled for the Fleet application. If this value is true, you must also include most of the SSO settings parameters below. | -| entity_id | string | The required entity ID is a URI that you use to identify Fleet when configuring the identity provider. | +| entity_id | string | The required entity ID is a URI that you use to identify Fleet when configuring the identity provider. Must be 5 or more characters. | | issuer_uri | string | The URI you provide here must exactly match the Entity ID field used in the identity provider configuration. | | idp_image_url | string | An optional link to an image such as a logo for the identity provider. | -| metadata_url | string | A URL that references the identity provider metadata. If available from the identity provider, this is the preferred means of providing metadata. | -| metadata | string | Metadata provided by the identity provider. Either `metadata` or a `metadata_url` must be provided. | +| metadata_url | string | A URL that references the identity provider metadata. If available from the identity provider, this is the preferred means of providing metadata. Must be either https or http | +| metadata | string | Metadata provided by the identity provider. Either `metadata` or a `metadata_url` must be provided. | | enable_sso_idp_login | boolean | Determines whether Identity Provider (IdP) initiated login for Single sign-on (SSO) is enabled for the Fleet application. | | enable_jit_provisioning | boolean | _Available in Fleet Premium._ When enabled, allows [just-in-time user provisioning](https://fleetdm.com/docs/deploy/single-sign-on-sso#just-in-time-jit-user-provisioning). | @@ -1434,9 +1460,9 @@ Modifies the Fleet's configuration with the supplied information. #### host_expiry_settings | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | host_expiry_enabled | boolean | When enabled, allows automatic cleanup of hosts that have not communicated with Fleet in some number of days. | -| host_expiry_window | integer | If a host has not communicated with Fleet in the specified number of days, it will be removed. | +| host_expiry_window | integer | If a host has not communicated with Fleet in the specified number of days, it will be removed. Must be greater than 0 if host_expiry_enabled is set to true. |
@@ -1454,9 +1480,9 @@ Modifies the Fleet's configuration with the supplied information. #### activity_expiry_settings | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| activity_expiry_enabled | boolean | When enabled, allows automatic cleanup of activities (and associated live query data) older than the specified number of days. | -| activity_expiry_window | integer | The number of days to retain activity records, if activity expiry is enabled. | +| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| activity_expiry_enabled | boolean | When enabled, allows automatic cleanup of activities (and associated live query data) older than the specified number of days. | +| activity_expiry_window | integer | The number of days to retain activity records, if activity expiry is enabled. |
@@ -1476,8 +1502,8 @@ Modifies the Fleet's configuration with the supplied information. _Available in Fleet Premium._ | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| transparency_url | string | The URL used to display transparency information to users of Fleet Desktop. | +| --------------------- | ------- | -------------------------------------------------------------------------------- | +| transparency_url | string | The URL used to display transparency information to users of Fleet Desktop. |
@@ -1500,12 +1526,12 @@ _Available in Fleet Premium._ + [`webhook_settings.activities_webhook`](#webhook-settings-activities-webhook) --> -| Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| host_status_webhook | list | See [`webhook_settings.host_status_webhook`](#webhook-settings-host-status-webhook). | -| failing_policies_webhook | list | See [`webhook_settings.failing_policies_webhook`](#webhook-settings-failing-policies-webhook). | -| vulnerabilities_webhook | list | See [`webhook_settings.vulnerabilities_webhook`](#webhook-settings-vulnerabilities-webhook). | -| activities_webhook | list | See [`webhook_settings.activities_webhook`](#webhook-settings-activities-webhook). | +| Name | Type | Description | +| --------------------- | ----- | ---------------------------------------------------------------------------------------------- | +| host_status_webhook | array | See [`webhook_settings.host_status_webhook`](#webhook-settings-host-status-webhook). | +| failing_policies_webhook | array | See [`webhook_settings.failing_policies_webhook`](#webhook-settings-failing-policies-webhook). | +| vulnerabilities_webhook | array | See [`webhook_settings.vulnerabilities_webhook`](#webhook-settings-vulnerabilities-webhook). | +| activities_webhook | array | See [`webhook_settings.activities_webhook`](#webhook-settings-activities-webhook). |
@@ -1514,11 +1540,11 @@ _Available in Fleet Premium._ `webhook_settings.host_status_webhook` is an object with the following structure: | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_host_status_webhook | boolean | Whether or not the host status webhook is enabled. | -| destination_url | string | The URL to deliver the webhook request to. | -| host_percentage | integer | The minimum percentage of hosts that must fail to check in to Fleet in order to trigger the webhook request. | -| days_count | integer | The minimum number of days that the configured `host_percentage` must fail to check in to Fleet in order to trigger the webhook request. | +| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| enable_host_status_webhook | boolean | Whether or not the host status webhook is enabled. | +| destination_url | string | The URL to deliver the webhook request to. | +| host_percentage | integer | The minimum percentage of hosts that must fail to check in to Fleet in order to trigger the webhook request. | +| days_count | integer | The minimum number of days that the configured `host_percentage` must fail to check in to Fleet in order to trigger the webhook request. |
@@ -1527,9 +1553,9 @@ _Available in Fleet Premium._ `webhook_settings.failing_policies_webhook` is an object with the following structure: | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_failing_policies_webhook | boolean | Whether or not the failing policies webhook is enabled. | -| destination_url | string | The URL to deliver the webhook requests to. | +| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- | +| enable_failing_policies_webhook | boolean | Whether or not the failing policies webhook is enabled. | +| destination_url | string | The URL to deliver the webhook requests to. | | policy_ids | array | List of policy IDs to enable failing policies webhook. | | host_batch_size | integer | Maximum number of hosts to batch on failing policy webhook requests. The default, 0, means no batching (all hosts failing a policy are sent on one request). | @@ -1540,9 +1566,9 @@ _Available in Fleet Premium._ `webhook_settings.vulnerabilities_webhook` is an object with the following structure: | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_vulnerabilities_webhook | boolean | Whether or not the vulnerabilities webhook is enabled. | -| destination_url | string | The URL to deliver the webhook requests to. | +| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| enable_vulnerabilities_webhook | boolean | Whether or not the vulnerabilities webhook is enabled. | +| destination_url | string | The URL to deliver the webhook requests to. | | host_batch_size | integer | Maximum number of hosts to batch on vulnerabilities webhook requests. The default, 0, means no batching (all vulnerable hosts are sent on one request). |
@@ -1552,9 +1578,9 @@ _Available in Fleet Premium._ `webhook_settings.activities_webhook` is an object with the following structure: | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_activities_webhook | boolean | Whether or not the activity feed webhook is enabled. | -| destination_url | string | The URL to deliver the webhook requests to. | +| --------------------- | ------- | --------------------------------------------------------- | +| enable_activities_webhook | boolean | Whether or not the activity feed webhook is enabled. | +| destination_url | string | The URL to deliver the webhook requests to. |
@@ -1596,11 +1622,11 @@ _Available in Fleet Premium._ + [`integrations.google_calendar`](#integrations-google-calendar) --> -| Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| jira | list | See [`integrations.jira`](#integrations-jira). | -| zendesk | list | See [`integrations.zendesk`](#integrations-zendesk). | -| google_calendar | list | See [`integrations.google_calendar`](#integrations-google-calendar). | +| Name | Type | Description | +| --------------------- | ----- | -------------------------------------------------------------------- | +| jira | array | See [`integrations.jira`](#integrations-jira). | +| zendesk | array | See [`integrations.zendesk`](#integrations-zendesk). | +| google_calendar | array | See [`integrations.google_calendar`](#integrations-google-calendar). | > Note that when making changes to the `integrations` object, all integrations must be provided (not just the one being modified). This is because the endpoint will consider missing integrations as deleted. @@ -1642,8 +1668,8 @@ _Available in Fleet Premium._ `integrations.google_calendar` is an array of objects with the following structure: | Name | Type | Description | -| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| domain | string | The domain for the Google Workspace service account to be used for this calendar integration. | +| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | +| domain | string | The domain for the Google Workspace service account to be used for this calendar integration. | | api_key_json | object | The private key JSON downloaded when generating the service account API key to be used for this calendar integration. |
@@ -1678,10 +1704,12 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| apple_bm_default_team | string | _Available in Fleet Premium._ The default team to use with Apple Business Manager. | | windows_enabled_and_configured | boolean | Enables Windows MDM support. | | enable_disk_encryption | boolean | _Available in Fleet Premium._ Hosts that belong to no team will have disk encryption enabled if set to true. | | macos_updates | object | See [`mdm.macos_updates`](#mdm-macos-updates). | +| ios_updates | object | See [`mdm.ios_updates`](#mdm-ios-updates). | +| ipados_updates | object | See [`mdm.ipados_updates`](#mdm-ipados-updates). | +| windows_updates | object | See [`mdm.window_updates`](#mdm-windows-updates). | | macos_migration | object | See [`mdm.macos_migration`](#mdm-macos-migration). | | macos_setup | object | See [`mdm.macos_setup`](#mdm-macos-setup). | | macos_settings | object | See [`mdm.macos_settings`](#mdm-macos-settings). | @@ -1702,6 +1730,32 @@ _Available in Fleet Premium._
+##### mdm.ios_updates + +_Available in Fleet Premium._ + +`mdm.ios_updates` is an object with the following structure: + +| Name | Type | Description | +| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| minimum_version | string | Hosts that belong to no team and are enrolled into Fleet's MDM will be nudged until their iOS is at or above this version. | +| deadline | string | Hosts that belong to no team and are enrolled into Fleet's MDM won't be able to dismiss the Nudge window once this deadline is past. | + +
+ +##### mdm.ipados_updates + +_Available in Fleet Premium._ + +`mdm.ipados_updates` is an object with the following structure: + +| Name | Type | Description | +| --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| minimum_version | string | Hosts that belong to no team and are enrolled into Fleet's MDM will be nudged until their iPadOS is at or above this version. | +| deadline | string | Hosts that belong to no team and are enrolled into Fleet's MDM won't be able to dismiss the Nudge window once this deadline is past. | + +
+ ##### mdm.windows_updates _Available in Fleet Premium._ @@ -1747,7 +1801,7 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| custom_settings | list | macOS hosts that belong to no team will have custom profiles applied. | +| custom_settings | array | macOS hosts that belong to no team will have custom profiles applied. |
@@ -1757,7 +1811,7 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| custom_settings | list | Windows hosts that belong to no team will have custom profiles applied. | +| custom_settings | array | Windows hosts that belong to no team will have custom profiles applied. |
@@ -1766,7 +1820,6 @@ _Available in Fleet Premium._ ```json { "mdm": { - "apple_bm_default_team": "", "windows_enabled_and_configured": false, "enable_disk_encryption": true, "macos_updates": { @@ -2054,7 +2107,7 @@ Delete all of a team's existing enroll secrets | email | string | body | **Required.** The email of the invited user. This email will receive the invitation link. | | name | string | body | **Required.** The name of the invited user. | | sso_enabled | boolean | body | **Required.** Whether or not SSO will be enabled for the invited user. | -| teams | list | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | #### Example @@ -2254,7 +2307,7 @@ Verify the specified invite. | email | string | body | The email of the invited user. Updates on the email won't resend the invitation. | | name | string | body | The name of the invited user. | | sso_enabled | boolean | body | Whether or not SSO will be enabled for the invited user. | -| teams | list | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | #### Example @@ -2378,7 +2431,6 @@ None. - [Get host OS version](#get-host-os-version) - [Get host's scripts](#get-hosts-scripts) - [Get host's software](#get-hosts-software) -- [Install software](#install-software) - [Get hosts report in CSV](#get-hosts-report-in-csv) - [Get host's disk encryption key](#get-hosts-disk-encryption-key) - [Lock host](#lock-host) @@ -2445,6 +2497,7 @@ the `software` table. | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | +| connected_to_fleet | boolean | query | Filter hosts that are talking to this Fleet server for MDM features. In rare cases, hosts can be enrolled to one Fleet server but talk to a different Fleet server for MDM features. In this case, the value would be `false`. Always `false` for Linux hosts. | | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | @@ -3055,10 +3108,11 @@ Returns the information of the specified host. "timezone": "America/New_York" }, "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null, + "encryption_key_available": true, + "enrollment_status": "On (manual)", + "name": "Fleet", + "connected_to_fleet": true, + "server_url": "https://acme.com/mdm/apple/mdm", "device_status": "unlocked", "pending_action": "", "macos_settings": { @@ -3474,10 +3528,11 @@ This is the API route used by the **My device** page in Fleet desktop to display } ], "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null, + "encryption_key_available": true, + "enrollment_status": "On (manual)", + "name": "Fleet", + "connected_to_fleet": true, + "server_url": "https://acme.com/mdm/apple/mdm", "macos_settings": { "disk_encryption": null, "action_required": null @@ -3504,6 +3559,7 @@ This is the API route used by the **My device** page in Fleet desktop to display ] } }, + "self_service": true, "org_logo_url": "https://example.com/logo.jpg", "license": { "tier": "free", @@ -3651,7 +3707,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------- | ------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ids | list | body | A list of the host IDs you'd like to delete. If `ids` is specified, `filters` cannot be specified. | +| ids | array | body | A list of the host IDs you'd like to delete. If `ids` is specified, `filters` cannot be specified. | | filters | object | body | 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. If `filters` is specified, `id` cannot be specified. `label_id` and `status` cannot be used at the same time. | Either ids or filters are required. @@ -4106,139 +4162,6 @@ Resends a configuration profile for the specified host. `Status: 202` - -### List host OS versions - -Retrieves the aggregated host OS versions information. - -`GET /api/v1/fleet/os_versions` - -#### Parameters - -| Name | Type | In | Description | -| --- | --- | --- | --- | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. | -| 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` | -| 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). | -| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | - - -##### Default response - -`Status: 200` - -```json -{ - "count": 1 - "counts_updated_at": "2023-12-06T22:17:30Z", - "os_versions": [ - { - "os_version_id": 123, - "hosts_count": 21, - "name": "Microsoft Windows 11 Pro 23H2 10.0.22621.1234", - "name_only": "Microsoft Windows 11 Pro 23H2", - "version": "10.0.22621.1234", - "platform": "windows", - "generated_cpes": [], - "vulnerabilities": [ - { - "cve": "CVE-2022-30190", - "details_link": "https://nvd.nist.gov/vuln/detail/CVE-2022-30190", - "cvss_score": 7.8,// Available in Fleet Premium - "epss_probability": 0.9729,// Available in Fleet Premium - "cisa_known_exploit": false,// Available in Fleet Premium - "cve_published": "2022-06-01T00:15:00Z",// Available in Fleet Premium - "cve_description": "Microsoft Windows Support Diagnostic Tool (MSDT) Remote Code Execution Vulnerability.",// Available in Fleet Premium - "resolved_in_version": ""// Available in Fleet Premium - } - ] - } - ], - "meta": { - "has_next_results": false, - "has_previous_results": false - } -} -``` - -OS vulnerability data is currently available for Windows and macOS. For other platforms, `vulnerabilities` will be an empty array: - -```json -{ - "hosts_count": 1, - "name": "CentOS Linux 7.9.2009", - "name_only": "CentOS", - "version": "7.9.2009", - "platform": "rhel", - "generated_cpes": [], - "vulnerabilities": [] -} -``` - -### Get host OS version - -Retrieves information about the specified OS version. - -`GET /api/v1/fleet/os_versions/:id` - -#### Parameters - -| Name | Type | In | Description | -| ---- | ---- | -- | ----------- | -| id | integer | path | **Required.** The OS version's ID. | - -##### Default response - -`Status: 200` - -```json -{ - "counts_updated_at": "2023-12-06T22:17:30Z", - "os_version": { - "id": 123, - "hosts_count": 21, - "name": "Microsoft Windows 11 Pro 23H2 10.0.22621.1234", - "name_only": "Microsoft Windows 11 Pro 23H2", - "version": "10.0.22621.1234", - "platform": "windows", - "generated_cpes": [], - "vulnerabilities": [ - { - "cve": "CVE-2022-30190", - "details_link": "https://nvd.nist.gov/vuln/detail/CVE-2022-30190", - "created_at": "2024-07-01T00:15:00Z", - "cvss_score": 7.8,// Available in Fleet Premium - "epss_probability": 0.9729,// Available in Fleet Premium - "cisa_known_exploit": false,// Available in Fleet Premium - "cve_published": "2022-06-01T00:15:00Z",// Available in Fleet Premium - "cve_description": "Microsoft Windows Support Diagnostic Tool (MSDT) Remote Code Execution Vulnerability.",// Available in Fleet Premium - "resolved_in_version": ""// Available in Fleet Premium - } - ] - } -} -``` - -OS vulnerability data is currently available for Windows and macOS. For other platforms, `vulnerabilities` will be an empty array: - -```json -{ - "id": 321, - "hosts_count": 1, - "name": "CentOS Linux 7.9.2009", - "name_only": "CentOS", - "version": "7.9.2009", - "platform": "rhel", - "generated_cpes": [], - "vulnerabilities": [] -} -``` - - ### Get host's scripts `GET /api/v1/fleet/hosts/:id/scripts` @@ -4260,46 +4183,45 @@ OS vulnerability data is currently available for Windows and macOS. For other pl `Status: 200` ```json - "scripts": [ - { - "script_id": 3, - "name": "remove-zoom-artifacts.sh", - "last_execution": { - "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", - "executed_at": "2021-12-15T15:23:57Z", - "status": "error" - } - }, - { - "script_id": 5, - "name": "set-timezone.sh", - "last_execution": { - "id": "e797d6c6-3aae-11ee-be56-0242ac120002", - "executed_at": "2021-12-15T15:23:57Z", - "status": "pending" - } - }, - { - "script_id": 8, - "name": "uninstall-zoom.sh", - "last_execution": { - "id": "e797d6c6-3aae-11ee-be56-0242ac120002", - "executed_at": "2021-12-15T15:23:57Z", - "status": "ran" - } +"scripts": [ + { + "script_id": 3, + "name": "remove-zoom-artifacts.sh", + "last_execution": { + "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "error" + } + }, + { + "script_id": 5, + "name": "set-timezone.sh", + "last_execution": { + "id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "pending" + } + }, + { + "script_id": 8, + "name": "uninstall-zoom.sh", + "last_execution": { + "id": "e797d6c6-3aae-11ee-be56-0242ac120002", + "executed_at": "2021-12-15T15:23:57Z", + "status": "ran" } - ], - "meta": { - "has_next_results": false, - "has_previous_results": false } +], +"meta": { + "has_next_results": false, + "has_previous_results": false } ``` ### Get host's software -> The **new keys/values added in the app management features are experimental** and may change. You can find the upcoming breaking changes [here](https://github.com/fleetdm/fleet/pull/19291/files#diff-7246bc304b15c8865ed8eaa205e9c244d0a0314e4bae60cf553dc06147c38b64L4304-L4311). +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. `GET /api/v1/fleet/hosts/:id/software` @@ -4309,6 +4231,7 @@ OS vulnerability data is currently available for Windows and macOS. For other pl | ---- | ------- | ---- | ---------------------------- | | id | integer | path | **Required**. The host's ID. | | query | string | query | Search query keywords. Searchable fields include `name`. | +| available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | page | integer | query | Page number of the results to fetch.| | per_page | integer | query | Results per page.| @@ -4327,14 +4250,18 @@ OS vulnerability data is currently available for Windows and macOS. For other pl { "id": 121, "name": "Google Chrome.app", - "package_available_for_install": "GoogleChrome.pkg", - "self_service": true, + "software_package": { + "name": "GoogleChrome.pkg", + "version": "125.12.0.3", + "self_service": true, + "last_install": { + "install_uuid": "8bbb8ac2-b254-4387-8cba-4d8a0407368b", + "installed_at": "2024-05-15T15:23:57Z" + } + }, + "app_store_app": null "source": "apps", "status": "failed", - "last_install": { - "install_uuid": "8bbb8ac2-b254-4387-8cba-4d8a0407368b", - "installed_at": "2024-05-15T15:23:57Z" - }, "installed_versions": [ { "version": "121.0", @@ -4347,32 +4274,42 @@ OS vulnerability data is currently available for Windows and macOS. For other pl { "id": 134, "name": "Falcon.app", - "package_available_for_install": "FalconSensor-6.44.pkg", - "self_service": false, + "software_package": { + "name": "FalconSensor-6.44.pkg" + "self_service": false, + "last_install": null + "last_install": null, + "last_uninstall": { + "script_execution_id": "ed579e73-0f41-46c8-aaf4-3c1e5880ed27", + "uninstalled_at": "2024-05-15T15:23:57Z" + } + }, + "app_store_app": null "source": "", "status": null, - "last_install": null, + "status": "pending_uninstall", "installed_versions": [], }, { "id": 147, - "name": "Firefox.app", + "name": "Logic Pro", + "software_package": null + "app_store_app": { + "app_store_id": "1091189122" + "version": "2.04", + "last_install": { + "command_uuid": "0aa14ae5-58fe-491a-ac9a-e4ee2b3aac40", + "installed_at": "2024-05-15T15:23:57Z" + }, + }, "source": "apps", - "bundle_identifier": "org.mozilla.firefox", - "status": null, - "last_install": null, + "status": "installed", "installed_versions": [ { "version": "118.0", "last_opened_at": "2024-04-01T23:03:07Z", "vulnerabilities": ["CVE-2023-1234"], - "installed_paths": ["/Applications/Firefox.app"] - }, - { - "version": "119.0", - "last_opened_at": "2024-04-01T23:03:07Z", - "vulnerabilities": ["CVE-2023-4321","CVE-2023-7654"], - "installed_paths": ["/Downloads/Firefox.app"] + "installed_paths": ["/Applications/Logic Pro.app"] } ] }, @@ -4384,29 +4321,6 @@ OS vulnerability data is currently available for Windows and macOS. For other pl } ``` -### Install software - -_Available in Fleet Premium._ - -Install software on a macOS, Windows, or Linux (Ubuntu) host. Software title must have `software_package` added to be installed. - -`POST /api/v1/fleet/hosts/:id/software/install/:software_title_id` - -#### Parameters - -| Name | Type | In | Description | -| --------- | ---------- | ---- | -------------------------------------------- | -| id | integer | path | **Required**. The host's ID. | -| software_title_id | integer | path | **Required**. The software title's ID. | - -#### Example - -`POST /api/v1/fleet/hosts/123/software/install/3435` - -##### Default response - -`Status: 202` - ### Get hosts report in CSV Returns the list of hosts corresponding to the search criteria in CSV format, ready for download when @@ -4423,8 +4337,8 @@ requested by a web browser. | order_key | string | query | What to order results by. Can be any column in the hosts 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'. | | status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | -| query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | -| team_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified team. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | +| team_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified team. | | policy_id | integer | query | The ID of the policy to filter hosts by. | | policy_response | string | query | **Requires `policy_id`**. Valid options are 'passing' or 'failing'. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | | software_version_id | integer | query | The ID of the software version to filter hosts by. | @@ -4434,11 +4348,11 @@ requested by a web browser. | os_version | string | query | The version of the operating system to filter hosts by. `os_name` must also be specified with `os_version` | | vulnerability | string | query | The cve to filter hosts by (including "cve-" prefix, case-insensitive). | | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | -| mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | -| mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | +| mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | +| mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | -| low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include 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_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. | | bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | disable_failing_policies | boolean | query | If `true`, hosts will return failing policies as 0 (returned as the `issues` column) regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | @@ -4654,6 +4568,38 @@ To wipe a macOS, iOS, iPadOS, or Windows host, the host must have MDM turned on. ```json { "activities": [ + { + "created_at": "2023-07-27T14:35:08Z", + "actor_id": 1, + "actor_full_name": "Anna Chao", + "id": 4, + "actor_gravatar": "", + "actor_email": "", + "type": "uninstalled_software", + "details": { + "host_id": 1, + "host_display_name": "Marko’s MacBook Pro", + "software_title": "Adobe Acrobat.app", + "script_execution_id": "ecf22dba-07dc-40a9-b122-5480e948b756", + "status": "failed" + } + }, + { + "created_at": "2023-07-27T14:35:08Z", + "actor_id": 1, + "actor_full_name": "Anna Chao", + "id": 3, + "actor_gravatar": "", + "actor_email": "", + "type": "uninstalled_software", + "details": { + "host_id": 1, + "host_display_name": "Marko’s MacBook Pro", + "software_title": "Adobe Acrobat.app", + "script_execution_id": "ecf22dba-07dc-40a9-b122-5480e948b756", + "status": "uninstalled" + } + }, { "created_at": "2023-07-27T14:35:08Z", "id": 2, @@ -4716,8 +4662,25 @@ To wipe a macOS, iOS, iPadOS, or Windows host, the host must have MDM turned on. ```json { - "count": 2, + "count": 3, "activities": [ + { + "created_at": "2023-07-27T14:35:08Z", + "actor_id": 1, + "actor_full_name": "Anna Chao", + "uuid": "cc081637-fdf9-4d44-929f-96dfaec00f67", + "actor_gravatar": "", + "actor_email": "", + "type": "uninstalled_software", + "fleet_initiated_activity": false, + "details": { + "host_id": 1, + "host_display_name": "Marko's MacBook Pro", + "software_title": "Adobe Acrobat.app", + "script_execution_id": "ecf22dba-07dc-40a9-b122-5480e948b756", + "status": "pending_uninstall", + } + }, { "created_at": "2023-07-27T14:35:08Z", "uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf", @@ -4766,9 +4729,9 @@ Adds manual labels to a host. #### Parameters -| Name | Type | In | Description | -| ---- | ------- | ---- | ---------------------------- | -| labels | list | body | The list of label names to add to the host. | +| Name | Type | In | Description | +| ------ | ------- | ---- | ---------------------------- | +| labels | array | body | The list of label names to add to the host. | #### Example @@ -4795,9 +4758,9 @@ Removes manual labels from a host. #### Parameters -| Name | Type | In | Description | -| ---- | ------- | ---- | ---------------------------- | -| labels | list | body | The list of label names to delete from the host. | +| Name | Type | In | Description | +| ------ | ------- | ---- | ---------------------------- | +| labels | array | body | The list of label names to delete from the host. | #### Example @@ -4829,8 +4792,8 @@ The live query will stop if the targeted host is offline, or if the query times | Name | Type | In | Description | |-----------|-------|------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| id | integer | path | **Required**. The target host ID. | -| query | string | body | **Required**. The query SQL. | +| id | integer | path | **Required**. The target host ID. | +| query | string | body | **Required**. The query SQL. | #### Example @@ -5392,8 +5355,8 @@ Add a configuration profile to enforce custom settings on macOS and Windows host | ------------------------- | -------- | ---- | ------------------------------------------------------------------------------------------------------------- | | profile | file | form | **Required.** The .mobileconfig and JSON for macOS or XML for Windows file containing the profile. | | team_id | string | form | _Available in Fleet Premium_. The team ID for the profile. If specified, the profile is applied to only hosts that are assigned to the specified team. If not specified, the profile is applied to only to hosts that are not assigned to any team. | -| labels_include_all | array | form | _Available in Fleet Premium_. Profile will only be applied to hosts that have all of these labels. | -| labels_exclude_any | array | form | _Available in Fleet Premium_. Profile will be applied to hosts that don’t have any of these labels. | +| labels_include_all | array | form | _Available in Fleet Premium_. Profile will only be applied to hosts that have all of these labels. Only one of either `labels_include_all` or `labels_exclude_any` can be included in the request. | +| labels_exclude_any | array | form | _Available in Fleet Premium_. Profile will be applied to hosts that don’t have any of these labels. Only one of either `labels_include_all` or `labels_exclude_any` can be included in the request. | #### Example @@ -5510,7 +5473,8 @@ List all configuration profiles for macOS and Windows hosts enrolled to Fleet's "checksum": "dGVzdAo=", "labels_exclude_any": [ { - "name": "Label name 1" + "name": "Label name 1", + "id": 1 } ] }, @@ -5524,11 +5488,12 @@ List all configuration profiles for macOS and Windows hosts enrolled to Fleet's "checksum": "aCLemVr)", "labels_include_all": [ { - "name": "Label name 1", - "broken": true + "name": "Label name 2", + "broken": true, }, { - "name": "Label name 2" + "name": "Label name 3", + "id": 3 } ] } @@ -5576,10 +5541,12 @@ If one or more assigned labels are deleted the profile is considered broken (`br "labels_include_all": [ { "name": "Label name 1", + "id": 1 "broken": true }, { "name": "Label name 2", + "id": 2 } ] } @@ -6213,12 +6180,12 @@ Body: ## Commands -- [Run custom MDM command](#run-custom-mdm-command) -- [Get custom MDM command results](#get-custom-mdm-command-results) -- [List custom MDM commands](#list-custom-mdm-commands) +- [Run MDM command](#run-mdm-command) +- [Get MDM command results](#get-mdm-command-results) +- [List MDM commands](#list-mdm-commands) -### Run custom MDM command +### Run MDM command > `POST /api/v1/fleet/mdm/apple/enqueue` API endpoint is deprecated as of Fleet 4.40. It is maintained for backward compatibility. Please use the new API endpoint below. See old API endpoint docs [here](https://github.com/fleetdm/fleet/blob/fleet-v4.39.0/docs/REST%20API/rest-api.md#run-custom-mdm-command). @@ -6251,12 +6218,22 @@ Note that the `EraseDevice` and `DeviceLock` commands are _available in Fleet Pr ``` -### Get custom MDM command results +### Get MDM command results > `GET /api/v1/fleet/mdm/apple/commandresults` API endpoint is deprecated as of Fleet 4.40. It is maintained for backward compatibility. Please use the new API endpoint below. See old API endpoint docs [here](https://github.com/fleetdm/fleet/blob/fleet-v4.39.0/docs/REST%20API/rest-api.md#get-custom-mdm-command-results). This endpoint returns the results for a specific custom MDM command. +In the reponse, the possible `status` values for macOS, iOS, and iPadOS hosts are the following: + +* Pending: the command has yet to run on the host. The host will run the command the next time it comes online. +* NotNow: the host responded with "NotNow" status via the MDM protocol: the host received the command, but couldn’t execute it. The host will try to run the command the next time it comes online. +* Acknowledged: the host responded with "Acknowledged" status via the MDM protocol: the host processed the command successfully. +* Error: the host responded with "Error" status via the MDM protocol: an error occurred. Run the `fleetctl get mdm-command-results --id= Note: If the server has not yet received a result for a command, it will return an empty object (`{}`). - -### List custom MDM commands +### List MDM commands > `GET /api/v1/fleet/mdm/apple/commands` API endpoint is deprecated as of Fleet 4.40. It is maintained for backward compatibility. Please use the new API endpoint below. See old API endpoint docs [here](https://github.com/fleetdm/fleet/blob/fleet-v4.39.0/docs/REST%20API/rest-api.md#list-custom-mdm-commands). @@ -6348,7 +6324,8 @@ This endpoint returns the list of custom MDM commands that have been executed. ## Integrations - [Get Apple Push Notification service (APNs)](#get-apple-push-notification-service-apns) -- [Get Apple Business Manager (ABM)](#get-apple-business-manager-abm) +- [List Apple Business Manager (ABM) tokens](#list-apple-business-manager-abm-tokens) +- [List Volume Purchasing Program (VPP) tokens](#list-volume-purchasing-program-vpp-tokens) ### Get Apple Push Notification service (APNs) @@ -6375,11 +6352,11 @@ None. } ``` -### Get Apple Business Manager (ABM) +### List Apple Business Manager (ABM) tokens _Available in Fleet Premium_ -`GET /api/v1/fleet/abm` +`GET /api/v1/fleet/abm_tokens` #### Parameters @@ -6387,7 +6364,95 @@ None. #### Example -`GET /api/v1/fleet/abm` +`GET /api/v1/fleet/abm_tokens` + +##### Default response + +`Status: 200` + +```json +"abm_tokens": [ + { + "id": 1, + "apple_id": "apple@example.com", + "org_name": "Fleet Device Management Inc.", + "mdm_server_url": "https://example.com/mdm/apple/mdm", + "renew_date": "2023-11-29T00:00:00Z", + "terms_expired": false, + "macos_team": { + "name": "💻 Workstations", + "id" 1 + }, + "ios_team": { + "name": "📱🏢 Company-owned iPhones", + "id": 2 + }, + "ipados_team": { + "name": "🔳🏢 Company-owned iPads", + "id": 3 + } + } +] +``` + +### List Volume Purchasing Program (VPP) tokens + +_Available in Fleet Premium_ + +`GET /api/v1/fleet/vpp_tokens` + +#### Parameters + +None. + +#### Example + +`GET /api/v1/fleet/vpp_tokens` + +##### Default response + +`Status: 200` + +```json +"vpp_tokens": [ + { + "id": 1, + "org_name": "Fleet Device Management Inc.", + "location": "https://example.com/mdm/apple/mdm", + "renew_date": "2023-11-29T00:00:00Z", + "teams": [ + { + "name": "💻 Workstations", + "id": 1 + }, + { + "name": "💻🐣 Workstations (canary)", + "id": 2 + }, + { + "name": "📱🏢 Company-owned iPhones", + "id": 3 + }, + { + "name": "🔳🏢 Company-owned iPads", + "id" 4 + } + ], + } +] +``` + +Get Volume Purchasing Program (VPP) + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium_ + +`GET /api/v1/fleet/vpp` + +#### Example + +`GET /api/v1/fleet/vpp` ##### Default response @@ -6395,11 +6460,9 @@ None. ```json { - "apple_id": "apple@example.com", - "org_name": "Fleet Device Management", - "mdm_server_url": "https://example.com/mdm/apple/mdm", + "org_name": "Acme Inc.", "renew_date": "2023-11-29T00:00:00Z", - "default_team": "" + "location": "Acme Inc. Main Address" } ``` @@ -6623,7 +6686,7 @@ For example, a policy might ask “Is Gatekeeper enabled on macOS devices?“ Th | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | -| ids | list | body | **Required.** The IDs of the policies to delete. | +| ids | array | body | **Required.** The IDs of the policies to delete. | #### Example @@ -6717,8 +6780,8 @@ Triggers [automations](https://fleetdm.com/docs/using-fleet/automations#policy-a | Name | Type | In | Description | | ---------- | -------- | ---- | -------------------------------------------------------- | -| policy_ids | list | body | Filters to only run policy automations for the specified policies. | -| team_ids | list | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified teams. | +| policy_ids | array | body | Filters to only run policy automations for the specified policies. | +| team_ids | array | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified teams. | #### Example @@ -6820,6 +6883,29 @@ Team policies work the same as policies, but at the team level. "failing_host_count": 0, "host_count_updated_at": "2023-12-20T15:23:57Z", "calendar_events_enabled": false + }, + { + "id": 3, + "name": "macOS - install/update Adobe Acrobat", + "query": "SELECT 1 FROM apps WHERE name = \"Adobe Acrobat.app\" AND bundle_short_version != \"24.002.21005\";", + "description": "Checks if the hard disk is encrypted on Windows devices", + "critical": false, + "author_id": 43, + "author_name": "Alice", + "author_email": "alice@example.com", + "team_id": 1, + "resolution": "Resolution steps", + "platform": "darwin", + "created_at": "2021-12-16T14:37:37Z", + "updated_at": "2021-12-16T16:39:00Z", + "passing_host_count": 2300, + "failing_host_count": 3, + "host_count_updated_at": "2023-12-20T15:23:57Z", + "calendar_events_enabled": false, + "install_software": { + "name": "Adobe Acrobat.app", + "software_title_id": 1234 + } } ], "inherited_policies": [ @@ -7001,6 +7087,7 @@ The semantics for creating a team policy are the same as for global policies, se | resolution | string | body | The resolution steps for the policy. | | platform | string | body | Comma-separated target platforms, currently supported values are "windows", "linux", "darwin". The default, an empty string means target all platforms. | | critical | boolean | body | _Available in Fleet Premium_. Mark policy as critical/high impact. | +| software_title_id | integer | body | _Available in Fleet Premium_. ID of software title to install if the policy fails. | Either `query` or `query_id` must be provided. @@ -7044,7 +7131,11 @@ Either `query` or `query_id` must be provided. "passing_host_count": 0, "failing_host_count": 0, "host_count_updated_at": null, - "calendar_events_enabled": false + "calendar_events_enabled": false, + "install_software": { + "name": "Adobe Acrobat.app", + "software_title_id": 1234 + } } } ``` @@ -7058,7 +7149,7 @@ Either `query` or `query_id` must be provided. | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | | team_id | integer | path | **Required.** Defines what team ID to operate on | -| ids | list | body | **Required.** The IDs of the policies to delete. | +| ids | array | body | **Required.** The IDs of the policies to delete. | #### Example @@ -7099,6 +7190,7 @@ Either `query` or `query_id` must be provided. | platform | string | body | Comma-separated target platforms, currently supported values are "windows", "linux", "darwin". The default, an empty string means target all platforms. | | critical | boolean | body | _Available in Fleet Premium_. Mark policy as critical/high impact. | | calendar_events_enabled | boolean | body | _Available in Fleet Premium_. Whether to trigger calendar events when policy is failing. | +| software_title_id | integer | body | _Available in Fleet Premium_. ID of software title to install if the policy fails. | #### Example @@ -7140,7 +7232,11 @@ Either `query` or `query_id` must be provided. "passing_host_count": 0, "failing_host_count": 0, "host_count_updated_at": null, - "calendar_events_enabled": true + "calendar_events_enabled": true, + "install_software": { + "name": "Adobe Acrobat.app", + "software_title_id": 1234 + } } } ``` @@ -7495,14 +7591,14 @@ Creates a global query or team query. | name | string | body | **Required**. The name of the 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). | +| observer_can_run | boolean | 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 | _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. | +| 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. | | automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | -| logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | bool | body | Whether to skip saving the latest query results for each host. Default: `false`. | +| logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | +| discard_data | boolean | body | Whether to skip saving the latest query results for each host. Default: `false`. | #### Example @@ -7569,13 +7665,13 @@ Modifies the query specified by ID. | name | string | body | The name of the query. | | query | string | body | 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). | +| observer_can_run | boolean | 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). | | 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 set to "", 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. | | automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | | logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | bool | body | Whether to skip saving the latest query results for each host. | +| discard_data | boolean | body | Whether to skip saving the latest query results for each host. | > Note that any of the following conditions will cause the existing query report to be deleted: > - Updating the `query` (SQL) field @@ -7679,9 +7775,9 @@ Deletes the queries specified by ID. Returns the count of queries successfully d #### Parameters -| Name | Type | In | Description | -| ---- | ---- | ---- | ------------------------------------- | -| ids | list | body | **Required.** The IDs of the queries. | +| Name | Type | In | Description | +| ---- | ----- | ---- | ------------------------------------- | +| ids | array | body | **Required.** The IDs of the queries. | #### Example @@ -8308,12 +8404,15 @@ Gets the result of a script that was executed. "host_timeout": false, "host_id": 1, "execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", - "runtime": 20 + "runtime": 20, + "created_at": "2024-09-11T20:30:24Z" } ``` > Note: `exit_code` can be `null` if Fleet hasn't heard back from the host yet. +> Note: `created_at` is the creation timestamp of the script execution request. + ### Add script Uploads a script, making it available to run on hosts assigned to the specified team (or no team). @@ -8538,176 +8637,29 @@ Deletes the session specified by ID. When the user associated with the session n ## Software -- [Add software](#add-software) -- [Download software](#download-software) -- [Delete software](#delete-software) -- [Get installation result](#get-installation-result) - [List software](#list-software) - [List software versions](#list-software-versions) +- [List operating systems](#list-operating-systems) - [Get software](#get-software) - [Get software version](#get-software-version) - -### Add software - -_Available in Fleet Premium._ - -Add a software package to install on macOS, Windows, and Linux (Ubuntu) hosts. - - -`POST /api/v1/fleet/software/package` - -#### Parameters - -| Name | Type | In | Description | -| ---- | ------- | ---- | -------------------------------------------- | -| software | file | form | **Required**. Installer package file. Supported packages are PKG, MSI, EXE, and DEB. | -| team_id | integer | form | **Required**. The team ID. Adds a software package to the specified team. | -| install_script | string | form | Command that Fleet runs to install software. If not specified Fleet runs [default install command](https://github.com/fleetdm/fleet/tree/f71a1f183cc6736205510580c8366153ea083a8d/pkg/file/scripts) for each package type. | -| pre_install_query | string | form | Query that is pre-install condition. If the query doesn't return any result, Fleet won't proceed to install. | -| post_install_script | string | form | The contents of the script to run after install. If the specified script fails (exit code non-zero) software install will be marked as failed and rolled back. | -| self_service | boolean | form | Self-service software is optional and can be installed by the end user. | - -#### Example - -`POST /api/v1/fleet/software/package` - -##### Request header - -```http -Content-Length: 8500 -Content-Type: multipart/form-data; boundary=------------------------d8c247122f594ba0 -``` - -##### Request body - -```http ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="team_id" -1 ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="self_service" -true ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="install_script" -sudo installer -pkg /temp/FalconSensor-6.44.pkg -target / ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="pre_install_query" -SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db'; ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="post_install_script" -sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX ---------------------------d8c247122f594ba0 -Content-Disposition: form-data; name="software"; filename="FalconSensor-6.44.pkg" -Content-Type: application/octet-stream - ---------------------------d8c247122f594ba0 -``` - -##### Default response - -`Status: 200` - - -### Download software - -_Available in Fleet Premium._ - -Download a software package. - -`GET /api/v1/fleet/software/titles/:software_title_id/package/?alt=media` - -#### Parameters - -| Name | Type | In | Description | -| ---- | ------- | ---- | -------------------------------------------- | -| software_title_id | integer | path | **Required**. The ID of the software title to download software package.| -| team_id | integer | query | **Required**. The team ID. Downloads a software package added to the specified team. | -| alt | integer | query | **Required**. If specified and set to "media", downloads the specified software package. | - -#### Example - -`GET /api/v1/fleet/software/titles/123/package?alt=media?team_id=2` - -##### Default response - -`Status: 200` - -```http -Status: 200 -Content-Type: application/octet-stream -Content-Disposition: attachment -Content-Length: -Body: -``` - -### Delete software - -> This **endpoint, added in the app management feature, is experimental** may change. You can find the upcoming breaking changes [here](https://github.com/fleetdm/fleet/pull/19291/files#diff-7246bc304b15c8865ed8eaa205e9c244d0a0314e4bae60cf553dc06147c38b64L8661-R8698). - -_Available in Fleet Premium._ - -Delete a software package. - -`DELETE /api/v1/fleet/software/titles/:software_title_id/package` - -#### Parameters - -| Name | Type | In | Description | -| ---- | ------- | ---- | -------------------------------------------- | -| software_title_id | integer | path | **Required**. The ID of the software title for the software package to delete. | -| team_id | integer | query | **Required**. The team ID. Deletes a software package added to the specified team. | - -#### Example - -`DELETE /api/v1/fleet/software/titles/24/package?team_id=2` - -##### Default response - -`Status: 204` - -### Get installation results - -_Available in Fleet Premium._ - -`GET /api/v1/fleet/software/install/results/:install_uuid` - -Get the results of a software installation. - -| Name | Type | In | Description | -| ---- | ------- | ---- | -------------------------------------------- | -| install_uuid | string | path | **Required**. The software installation UUID.| - -#### Example - -`GET /api/v1/fleet/software/install/results/b15ce221-e22e-4c6a-afe7-5b3400a017da` - -##### Default response - -`Status: 200` - -```json - { - "install_uuid": "b15ce221-e22e-4c6a-afe7-5b3400a017da", - "software_title": "Falcon.app", - "software_title_id": 8353, - "software_package": "FalconSensor-6.44.pkg", - "host_id": 123, - "host_display_name": "Marko's MacBook Pro", - "status": "failed", - "output": "Installing software...\nError: The operation can’t be completed because the item “Falcon” is in use.", - "pre_install_query_output": "Query returned result\nSuccess", - "post_install_script_output": "Running script...\nExit code: 1 (Failed)\nRolling back software install...\nSuccess" - } -``` +- [Get operating system version](#get-operating-system-version) +- [Add package](#add-package) +- [List App Store apps](#list-app-store-apps) +- [Add App Store app](#add-app-store-app) +- [Add Fleet library app](#add-fleet-library-app) +- [Install package or App Store app](#install-package-or-app-store-app) +- [Get package install result](#get-package-install-result) +- [Download package](#download-package) +- [Delete package or App Store app](#delete-package-or-app-store-app) ### List software -> The **new keys/values added in the app management feature are experimental** and may change. You can find the upcoming breaking changes [here](https://github.com/fleetdm/fleet/pull/19291/files#diff-7246bc304b15c8865ed8eaa205e9c244d0a0314e4bae60cf553dc06147c38b64L8749-R8791). - Get a list of all software. `GET /api/v1/fleet/software/titles` +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + #### Parameters | Name | Type | In | Description | @@ -8717,10 +8669,13 @@ Get a list of all software. | order_key | string | query | What to order results by. Allowed fields are `name` and `hosts_count`. Default is `hosts_count` (descending). | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | | query | string | query | Search query keywords. Searchable fields include `title` and `cve`. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. | -| vulnerable | bool | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | -| available_for_install | bool | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | -| self_service | bool | query | If `true` or `1`, only lists self-service software. Default is `false`. | +| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | +| vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | +| available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | +| self_service | boolean | query | If `true` or `1`, only lists self-service software. Default is `false`. | +| min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. | +| max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. | +| exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. | #### Example @@ -8738,8 +8693,12 @@ Get a list of all software. { "id": 12, "name": "Firefox.app", - "software_package": "FirefoxInstall.pkg", - "self_service": true, + "software_package": { + "name": "FirefoxInsall.pkg", + "version": "125.6", + "self_service": true + }, + "app_store_app": null, "versions_count": 3, "source": "apps", "browser": "", @@ -8766,7 +8725,7 @@ Get a list of all software. "id": 22, "name": "Google Chrome.app", "software_package": null, - "self_service": false, + "app_store_app": null, "versions_count": 5, "source": "apps", "browser": "", @@ -8798,7 +8757,7 @@ Get a list of all software. "id": 32, "name": "1Password – Password Manager", "software_package": null, - "self_service": false, + "app_store_app": null, "versions_count": 1, "source": "chrome_extensions", "browser": "chrome", @@ -8834,8 +8793,11 @@ Get a list of all software versions. | order_key | string | query | What to order results by. Allowed fields are `name`, `hosts_count`, `cve_published`, `cvss_score`, `epss_probability` and `cisa_known_exploit`. Default is `hosts_count` (descending). | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | | query | string | query | Search query keywords. Searchable fields include `name`, `version`, and `cve`. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. | -| vulnerable | bool | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | +| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | +| vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | +| min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. | +| max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. | +| exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. | #### Example @@ -8893,8 +8855,82 @@ Get a list of all software versions. } ``` +### List operating systems + +Returns a list of all operating systems. + +`GET /api/v1/fleet/os_versions` + +#### Parameters + +| Name | Type | In | Description | +| --- | --- | --- | --- | +| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| 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` | +| 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). | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | + + +##### Default response + +`Status: 200` + +```json +{ + "count": 1 + "counts_updated_at": "2023-12-06T22:17:30Z", + "os_versions": [ + { + "os_version_id": 123, + "hosts_count": 21, + "name": "Microsoft Windows 11 Pro 23H2 10.0.22621.1234", + "name_only": "Microsoft Windows 11 Pro 23H2", + "version": "10.0.22621.1234", + "platform": "windows", + "generated_cpes": [], + "vulnerabilities": [ + { + "cve": "CVE-2022-30190", + "details_link": "https://nvd.nist.gov/vuln/detail/CVE-2022-30190", + "cvss_score": 7.8,// Available in Fleet Premium + "epss_probability": 0.9729,// Available in Fleet Premium + "cisa_known_exploit": false,// Available in Fleet Premium + "cve_published": "2022-06-01T00:15:00Z",// Available in Fleet Premium + "cve_description": "Microsoft Windows Support Diagnostic Tool (MSDT) Remote Code Execution Vulnerability.",// Available in Fleet Premium + "resolved_in_version": ""// Available in Fleet Premium + } + ] + } + ], + "meta": { + "has_next_results": false, + "has_previous_results": false + } +} +``` + +OS vulnerability data is currently available for Windows and macOS. For other platforms, `vulnerabilities` will be an empty array: + +```json +{ + "hosts_count": 1, + "name": "CentOS Linux 7.9.2009", + "name_only": "CentOS", + "version": "7.9.2009", + "platform": "rhel", + "generated_cpes": [], + "vulnerabilities": [] +} +``` + ### Get software +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + Returns information about the specified software. By default, `versions` are sorted in descending order by the `hosts_count` field. `GET /api/v1/fleet/software/titles/:id` @@ -8904,7 +8940,7 @@ Returns information about the specified software. By default, `versions` are sor | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The software title's ID. | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. | +| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | #### Example @@ -8919,22 +8955,27 @@ Returns information about the specified software. By default, `versions` are sor "software_title": { "id": 12, "name": "Firefox.app", + "bundle_identifier": "org.mozilla.firefox", "software_package": { "name": "FalconSensor-6.44.pkg", "version": "6.44", "installer_id": 23, "team_id": 3, "uploaded_at": "2024-04-01T14:22:58Z", - "install_script": "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /", + "install_script": "sudo installer -pkg '$INSTALLER_PATH' -target /", "pre_install_query": "SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db';", "post_install_script": "sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX", + "uninstall_script": "/Library/CS/falconctl uninstall", "self_service": true, "status": { "installed": 3, - "pending": 1, - "failed": 2, + "pending_install": 1, + "failed_install": 0, + "pending_uninstall": 2, + "failed_uninstall": 1 } }, + "app_store_app": null, "source": "apps", "browser": "", "hosts_count": 48, @@ -8962,6 +9003,47 @@ Returns information about the specified software. By default, `versions` are sor } ``` +#### Example (App Store app) + +`GET /api/v1/fleet/software/titles/15` + +##### Default response + +`Status: 200` + +```json +{ + "software_title": { + "id": 15, + "name": "Logic Pro", + "bundle_identifier": "com.apple.logic10", + "software_package": null, + "app_store_app": { + "name": "Logic Pro", + "app_store_id": "1091189122", + "latest_version": "2.04", + "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f1/65/1e/a4844ccd-486d-455f-bb31-67336fe46b14/AppIcon-1x_U007emarketing-0-7-0-85-220-0.png/512x512bb.jpg", + "status": { + "installed": 3, + "pending": 1, + "failed": 2, + } + }, + "source": "apps", + "browser": "", + "hosts_count": 48, + "versions": [ + { + "id": 123, + "version": "2.04", + "vulnerabilities": [], + "hosts_count": 24 + } + ] + } +} +``` + ### Get software version Returns information about the specified software version. @@ -8973,7 +9055,7 @@ Returns information about the specified software version. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The software version's ID. | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. | +| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | #### Example @@ -9019,8 +9101,441 @@ Returns information about the specified software version. } ``` -## Vulnerabilities +### Get operating system version +Retrieves information about the specified operating system (OS) version. + +`GET /api/v1/fleet/os_versions/:id` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| id | integer | path | **Required.** The OS version's ID. | +| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | + +##### Default response + +`Status: 200` + +```json +{ + "counts_updated_at": "2023-12-06T22:17:30Z", + "os_version": { + "id": 123, + "hosts_count": 21, + "name": "Microsoft Windows 11 Pro 23H2 10.0.22621.1234", + "name_only": "Microsoft Windows 11 Pro 23H2", + "version": "10.0.22621.1234", + "platform": "windows", + "generated_cpes": [], + "vulnerabilities": [ + { + "cve": "CVE-2022-30190", + "details_link": "https://nvd.nist.gov/vuln/detail/CVE-2022-30190", + "created_at": "2024-07-01T00:15:00Z", + "cvss_score": 7.8,// Available in Fleet Premium + "epss_probability": 0.9729,// Available in Fleet Premium + "cisa_known_exploit": false,// Available in Fleet Premium + "cve_published": "2022-06-01T00:15:00Z",// Available in Fleet Premium + "cve_description": "Microsoft Windows Support Diagnostic Tool (MSDT) Remote Code Execution Vulnerability.",// Available in Fleet Premium + "resolved_in_version": ""// Available in Fleet Premium + } + ] + } +} +``` + +OS vulnerability data is currently available for Windows and macOS. For other platforms, `vulnerabilities` will be an empty array: + +```json +{ + "id": 321, + "hosts_count": 1, + "name": "CentOS Linux 7.9.2009", + "name_only": "CentOS", + "version": "7.9.2009", + "platform": "rhel", + "generated_cpes": [], + "vulnerabilities": [] +} +``` + +### Add package + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +Add a package (.pkg, .msi, .exe, .deb) to install on macOS, Windows, or Linux (Ubuntu) hosts. + + +`POST /api/v1/fleet/software/package` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| software | file | form | **Required**. Installer package file. Supported packages are PKG, MSI, EXE, and DEB. | +| team_id | integer | form | **Required**. The team ID. Adds a software package to the specified team. | +| install_script | string | form | Script that Fleet runs to install software. If not specified Fleet runs [default install script](https://github.com/fleetdm/fleet/tree/f71a1f183cc6736205510580c8366153ea083a8d/pkg/file/scripts) for each package type. | +| pre_install_query | string | form | Query that is pre-install condition. If the query doesn't return any result, Fleet won't proceed to install. | +| post_install_script | string | form | The contents of the script to run after install. If the specified script fails (exit code non-zero) software install will be marked as failed and rolled back. | +| self_service | boolean | form | Self-service software is optional and can be installed by the end user. | + +#### Example + +`POST /api/v1/fleet/software/package` + +##### Request header + +```http +Content-Length: 8500 +Content-Type: multipart/form-data; boundary=------------------------d8c247122f594ba0 +``` + +##### Request body + +```http +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="team_id" +1 +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="self_service" +true +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="install_script" +sudo installer -pkg /temp/FalconSensor-6.44.pkg -target / +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="pre_install_query" +SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db'; +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="post_install_script" +sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="software"; filename="FalconSensor-6.44.pkg" +Content-Type: application/octet-stream + +--------------------------d8c247122f594ba0 +``` + +##### Default response + +`Status: 200` + +### Modify package + +_Available in Fleet Premium._ + +Update a package to install on macOS, Windows, or Linux (Ubuntu) hosts. + +`PATCH /api/v1/fleet/software/titles/:title_id/package` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| software | file | form | Installer package file. Supported packages are PKG, MSI, EXE, and DEB. | +| team_id | integer | form | **Required**. The team ID. Updates a software package in the specified team. | +| install_script | string | form | Command that Fleet runs to install software. If not specified Fleet runs the [default install command](https://github.com/fleetdm/fleet/tree/f71a1f183cc6736205510580c8366153ea083a8d/pkg/file/scripts) for each package type. | +| pre_install_query | string | form | Query that is pre-install condition. If the query doesn't return any result, the package will not be installed. | +| post_install_script | string | form | The contents of the script to run after install. If the specified script fails (exit code non-zero) software install will be marked as failed and rolled back. | +| self_service | boolean | form | Whether this is optional self-service software that can be installed by the end user. | + +> Changes to the installer package will reset installation counts. Changes to any field other than `self_service` will cancel pending installs for the old package. +#### Example + +`PATCH /api/v1/fleet/software/titles/1/package` + +##### Request header + +```http +Content-Length: 8500 +Content-Type: multipart/form-data; boundary=------------------------d8c247122f594ba0 +``` + +##### Request body + +```http +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="team_id" +1 +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="self_service" +true +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="install_script" +sudo installer -pkg /temp/FalconSensor-6.44.pkg -target / +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="pre_install_query" +SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db'; +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="post_install_script" +sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX +--------------------------d8c247122f594ba0 +Content-Disposition: form-data; name="software"; filename="FalconSensor-6.44.pkg" +Content-Type: application/octet-stream + +--------------------------d8c247122f594ba0 +``` + +##### Default response + +`Status: 200` + +```json +{ + "software_package": { + "name": "FalconSensor-6.44.pkg", + "version": "6.44", + "installer_id": 23, + "team_id": 3, + "uploaded_at": "2024-04-01T14:22:58Z", + "install_script": "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /", + "pre_install_query": "SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db';", + "post_install_script": "sudo /Applications/Falcon.app/Contents/Resources/falconctl license 0123456789ABCDEFGHIJKLMNOPQRSTUV-WX", + "self_service": true, + "status": { + "installed": 0, + "pending": 0, + "failed": 0 + } + } +} +``` + +### List App Store apps + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +Returns the list of Apple App Store (VPP) that can be added to the specified team. If an app is already added to the team, it's excluded from the list. + +`GET /api/v1/fleet/software/app_store_apps` + +#### Parameters + +| Name | Type | In | Description | +| ------- | ---- | -- | ----------- | +| team_id | integer | query | **Required**. The team ID. | + +#### Example + +`GET /api/v1/fleet/software/app_store_apps/?team_id=3` + +##### Default response + +`Status: 200` + +```json +{ + "app_store_apps": [ + { + "name": "Xcode", + "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f1/65/1e/a4844ccd-486d-455f-bb31-67336fe46b14/AppIcon-1x_U007emarketing-0-7-0-85-220-0.png/512x512bb.jpg", + "latest_version": "15.4", + "app_store_id": "497799835", + "platform": "darwin" + }, + { + "name": "Logic Pro", + "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f1/65/1e/a4844ccd-486d-455f-bb31-67336fe46b14/AppIcon-1x_U007emarketing-0-7-0-85-220-0.png/512x512bb.jpg", + "latest_version": "2.04", + "app_store_id": "634148309", + "platform": "ios" + }, + { + "name": "Logic Pro", + "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/f1/65/1e/a4844ccd-486d-455f-bb31-67336fe46b14/AppIcon-1x_U007emarketing-0-7-0-85-220-0.png/512x512bb.jpg", + "latest_version": "2.04", + "app_store_id": "634148309", + "platform": "ipados" + }, + ] +} +``` + +### Add App Store app + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +Add App Store (VPP) app purchased in Apple Business Manager. + +`POST /api/v1/fleet/software/app_store_apps` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ---- | -- | ----------- | +| app_store_id | string | body | **Required.** The ID of App Store app. | +| team_id | integer | body | **Required**. The team ID. Adds VPP software to the specified team. | +| platform | string | body | The platform of the app (`darwin`, `ios`, or `ipados`). Default is `darwin`. | + +#### Example + +`POST /api/v1/fleet/software/app_store_apps?team_id=3` + +##### Request body + +```json +{ + "app_store_id": "497799835", + "team_id": 2, + "platform": "ipados" +} +``` + +##### Default response + +`Status: 200` + +### Download package + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +`GET /api/v1/fleet/software/titles/:software_title_id/package?alt=media` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| software_title_id | integer | path | **Required**. The ID of the software title to download software package.| +| team_id | integer | query | **Required**. The team ID. Downloads a software package added to the specified team. | +| alt | integer | query | **Required**. If specified and set to "media", downloads the specified software package. | + +#### Example + +`GET /api/v1/fleet/software/titles/123/package?alt=media?team_id=2` + +##### Default response + +`Status: 200` + +```http +Status: 200 +Content-Type: application/octet-stream +Content-Disposition: attachment +Content-Length: +Body: +``` + +### Install package or App Store app + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +Install software (package or App Store app) on a macOS, iOS, iPadOS, Windows, or Linux (Ubuntu) host. Software title must have a `software_package` or `app_store_app` added to be installed. + +`POST /api/v1/fleet/hosts/:id/software/:software_title_id/install` + +#### Parameters + +| Name | Type | In | Description | +| --------- | ---------- | ---- | -------------------------------------------- | +| id | integer | path | **Required**. The host's ID. | +| software_title_id | integer | path | **Required**. The software title's ID. | + +#### Example + +`POST /api/v1/fleet/hosts/123/software/3435/install` + +##### Default response + +`Status: 202` + +### Uninstall package + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. +_Available in Fleet Premium._ + +Uninstall software (package) on a macOS, Windows, or Linux (Ubuntu) host. Software title must have a `software_package` added to be uninstalled. + +`POST /api/v1/fleet/hosts/:id/software/:software_title_id/uninstall` + +#### Parameters + +| Name | Type | In | Description | +| --------- | ---------- | ---- | -------------------------------------------- | +| id | integer | path | **Required**. The host's ID. | +| software_title_id | integer | path | **Required**. The software title's ID. | + +#### Example + +`POST /api/v1/fleet/hosts/123/software/3435/uninstall` + +##### Default response + +`Status: 202` + +### Get package install result + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +`GET /api/v1/fleet/software/install/:install_uuid/results` + +Get the results of a software package install. + +To get the results of an App Store app install, use the [List MDM commands](#list-mdm-commands) and [Get MDM command results](#get-mdm-command-results) API enpoints. Fleet uses an MDM command to install App Store apps. + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| install_uuid | string | path | **Required**. The software installation UUID.| + +#### Example + +`GET /api/v1/fleet/software/install/b15ce221-e22e-4c6a-afe7-5b3400a017da/results` + +##### Default response + +`Status: 200` + +```json + { + "install_uuid": "b15ce221-e22e-4c6a-afe7-5b3400a017da", + "software_title": "Falcon.app", + "software_title_id": 8353, + "software_package": "FalconSensor-6.44.pkg", + "host_id": 123, + "host_display_name": "Marko's MacBook Pro", + "status": "failed", + "output": "Installing software...\nError: The operation can’t be completed because the item “Falcon” is in use.", + "pre_install_query_output": "Query returned result\nSuccess", + "post_install_script_output": "Running script...\nExit code: 1 (Failed)\nRolling back software install...\nSuccess" + } +``` + +### Delete package or App Store app + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + +_Available in Fleet Premium._ + +Deletes software that's available for install (package or App Store app). + +`DELETE /api/v1/fleet/software/titles/:software_title_id/available_for_install` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | -------------------------------------------- | +| software_title_id | integer | path | **Required**. The ID of the software title to delete software available for install. | +| team_id | integer | query | **Required**. The team ID. Deletes a software package added to the specified team. | + +#### Example + +`DELETE /api/v1/fleet/software/titles/24/available_for_install?team_id=2` + +##### Default response + +`Status: 204` + +## Vulnerabilities - [List vulnerabilities](#list-vulnerabilities) - [Get vulnerability](#get-vulnerability) @@ -9035,7 +9550,7 @@ Retrieves a list of all CVEs affecting software and/or OS versions. | Name | Type | In | Description | | --- | --- | --- | --- | -| team_id | integer | query | _Available in Fleet Premium_. Filters only include vulnerabilities affecting the specified team. | +| team_id | integer | query | _Available in Fleet Premium_. Filters only include vulnerabilities affecting the specified team. Use `0` to filter by hosts assigned to "No 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: `cve`, `cvss_score`, `epss_probability`, `cve_published`, `created_at`, and `host_count`. Default is `created_at` (descending). | @@ -9044,7 +9559,6 @@ Retrieves a list of all CVEs affecting software and/or OS versions. | exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Otherwise, includes vulnerabilities with any `cisa_known_exploit` value. | - ##### Default response `Status: 200` @@ -9079,12 +9593,14 @@ Retrieves a list of all CVEs affecting software and/or OS versions. Retrieve details about a vulnerability and its affected software and OS versions. +If no vulnerable OS versions or software were found, but Fleet is aware of the vulnerability, a 204 status code is returned. + #### Parameters -| Name | Type | In | Description | -| --- | --- | --- | --- | -| cve | string | path | The cve to get information about (including "cve-" prefix, case-insensitive). | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. | +| Name | Type | In | Description | +|---------|---------|-------|------------------------------------------------------------------------------------------------------------------------------| +| cve | string | path | The cve to get information about (format must be CVE-YYYY-<4 or more digits>, case-insensitive). | +| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | `GET /api/v1/fleet/vulnerabilities/:cve` @@ -9115,7 +9631,7 @@ Retrieve details about a vulnerability and its affected software and OS versions "name": "macOS 14.1.2", "name_only": "macOS", "version": "14.1.2", - "platform": "darwin", + "resolved_in_version": "14.2", "generated_cpes": [ "cpe:2.3:o:apple:macos:*:*:*:*:*:14.2:*:*", @@ -9597,8 +10113,8 @@ _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 on the team. | +| host_ids | array | body | A list of hosts that belong to the team. | +| user_ids | array | 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. | @@ -9623,14 +10139,20 @@ _Available in Fleet Premium_ |   macos_updates | object | body | macOS updates settings. | |     minimum_version | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM will be nudged until their macOS is at or above this version. | |     deadline | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM won't be able to dismiss the Nudge window once this deadline is past. | +|   ios_updates | object | body | iOS updates settings. | +|     minimum_version | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM will be nudged until their iOS is at or above this version. | +|     deadline | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM won't be able to dismiss the Nudge window once this deadline is past. | +|   ipados_updates | object | body | iPadOS updates settings. | +|     minimum_version | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM will be nudged until their iPadOS is at or above this version. | +|     deadline | string | body | Hosts that belong to this team and are enrolled into Fleet's MDM won't be able to dismiss the Nudge window once this deadline is past. | |   windows_updates | object | body | Windows updates settings. | |     deadline_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before updates are installed on Windows. | |     grace_period_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before Windows restarts to install updates. | |   macos_settings | object | body | macOS-specific settings. | -|     custom_settings | list | body | The list of objects where each object includes .mobileconfig or JSON file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | +|     custom_settings | array | body | The list of objects where each object includes .mobileconfig or JSON file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | |     enable_disk_encryption | boolean | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have disk encryption enabled if set to true. | |   windows_settings | object | body | Windows-specific settings. | -|     custom_settings | list | body | The list of objects where each object includes XML file (configuration profile) and label name to apply to Windows hosts that belong to this team and are members of the specified label. | +|     custom_settings | array | body | The list of objects where each object includes XML file (configuration profile) and label name to apply to Windows hosts that belong to this team and are members of the specified label. | |   macos_setup | object | body | Setup for automatic MDM enrollment of macOS hosts. | |     enable_end_user_authentication | boolean | body | If set to true, end user authentication will be required during automatic MDM enrollment of new macOS hosts. Settings for your IdP provider must also be [configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#end-user-authentication-and-eula). | | integrations | object | body | Integration settings for this team. | @@ -9850,8 +10372,8 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | --- | --- | --- | --- | | id | integer | path | **Required.** The desired team's ID. | -| force | bool | query | Force apply the options even if there are validation errors. | -| dry_run | bool | query | Validate the options and return any validation errors, but do not apply the changes. | +| force | boolean | query | Force apply the options even if there are validation errors. | +| dry_run | boolean | query | Validate the options and return any validation errors, but do not apply the changes. | | _JSON data_ | object | body | The JSON to use as agent options for this team. See [Agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) for details. | #### Example @@ -9962,9 +10484,9 @@ Transforms a host name into a host id. For example, the Fleet UI use this endpoi #### Parameters -| Name | Type | In | Description | -| ---- | ----- | ---- | ---------------------------------------- | -| list | array | body | **Required** list of items to translate. | +| Name | Type | In | Description | +| ----- | ----- | ---- | ---------------------------------------- | +| array | array | body | **Required** list of items to translate. | #### Example diff --git a/docs/Using Fleet/Learn-how-to-use-Fleet.md b/docs/Using Fleet/Learn-how-to-use-Fleet.md deleted file mode 100644 index fceea3f7db..0000000000 --- a/docs/Using Fleet/Learn-how-to-use-Fleet.md +++ /dev/null @@ -1,58 +0,0 @@ -# Learn how to use Fleet - -- [How to add your device to Fleet](#how-to-add-your-device-to-fleet) -- [How to ask questions about your device](#how-to-ask-questions-about-your-device) - -### Overview - -In this guide, we'll cover the following concepts: -- How to add your device to Fleet -- How to ask questions about your device - -### How to add your device to Fleet - -Once you log into Fleet, you are presented with the **Home** page. - -To add your device: - -1. Select **Add hosts**. In Fleet, devices are referred to as "hosts." -2. Select your device's platform. -3. Select **Download** to download Fleet's agent (fleetd). The download may take several seconds. -4. Open fleetd and follow the installation steps. - -> It may take several seconds for Fleet osquery to send your device's data to Fleet. - -In the background, Fleet ran several checks to assess the security hygiene of your device. - -> In Fleet, these checks are referred to as "policies." - -### How to ask questions about your device - -With Fleet, you can ask a multitude of questions to help you manage, monitor, and identify threats on your devices, but if you are just starting out, and unsure of what to ask, Fleet comes baked in with a [query library](https://fleetdm.com/queries) of common questions. - -So, let's start by asking the following question about your device: - -* What operating system is installed on my device and what is its version? - -This question can easily be answered by running this simple query: "Get operating system information." - -To run this query on your device: - -1. Select **Queries** in the top navigation. -2. Select **Create new query** (or browse your organization's queries for "operating system information" in the search bar). -3. Type the query you would like to run, `SELECT * FROM os_version;`. -4. Select **Run query**, then select **All hosts** (your device may be the only host added to Fleet), and finally select **Run** to execute the query. - -The query may take several seconds to complete, because Fleet has to wait for the Fleet's agent (fleetd) to respond with results. Only online hosts will respond with results to a live query. - -> Fleet's query response time is inherently variable because of osquery's heartbeat response time. This helps prevent performance issues on hosts. - -When the query has finished, you should see several columns in the "Results" table: - -- The "name" column answers: "What operating system is installed on my device?" - -- The "version" column answers: "What version of the installed operating system is on my device?" - - - - \ No newline at end of file diff --git a/docs/Using Fleet/MDM-migration-guide.md b/docs/Using Fleet/MDM-migration-guide.md deleted file mode 100644 index 68e55a7c81..0000000000 --- a/docs/Using Fleet/MDM-migration-guide.md +++ /dev/null @@ -1,214 +0,0 @@ -# Migration guide - -This section provides instructions for migrating your hosts away from your old MDM solution to Fleet. - -## Requirements - -1. A [deployed Fleet instance](../Deploying/Introduction.md) -2. [Fleet connected to Apple](./mdm-setup.md) - -## Migrate manually enrolled hosts - -1. [Enroll](./Adding-hosts.md) your hosts to Fleet with [Fleetd and Fleet Desktop](https://fleetdm.com/docs/using-fleet/adding-hosts#including-fleet-desktop) -2. Ensure your end users have access to an admin account on their Mac. End users won't be able to migrate on their own if they have a standard account. -3. In your old MDM solution, unenroll the hosts to be migrated. MacOS does not allow multiple MDMs to be installed at once. -4. Send [these guided instructions](#how-to-turn-on-mdm) to your end users to complete the final few steps via Fleet Desktop. - * Note that there will be a gap in MDM coverage between when the host is unenrolled from the old MDM and when the host turns on MDM in Fleet. - -### End user experience - -1. On their **My device** page, once an end user's device is unenrolled from the old MDM solution, the end user will be given the option to manually download the MDM enrollment profile. - -2. Once downloaded, the user will receive a system notification that the Device Enrollment profile needs to be installed in their **System Settings > Profiles** section. - -3. After installation, the MDM enrollment profile can be removed by the end user at any time. - -### How to turn on MDM - -1. Select the Fleet icon in your menu bar and select **My device**. - -![Fleet icon in menu bar](https://raw.githubusercontent.com/fleetdm/fleet/main/website/assets/images/articles/fleet-desktop-says-hello-world-cover-1600x900@2x.jpg) - -2. On your **My device** page, select the **Turn on MDM** button in the yellow banner and follow the instructions. - - If you don’t see the yellow banner or the **Turn on MDM** button, select the purple **Refetch** button at the top of the page. - - If you still don't see the **Turn on MDM** button or the **My device** page presents you with an error, please contact your IT administrator. - -My device page - turn on MDM - -## Migrate automatically enrolled (DEP) hosts - -> Automatic enrollment is available in Fleet Premium or Ultimate - -To migrate automatically enrolled hosts, we will do the following steps: - -1. Prepare to migrate hosts -2. Choose migration workflow and migrate hosts - -### Step 1: prepare to migrate hosts - -1. Connect Fleet to Apple Business Manager (ABM). Learn how [here](./mdm-setup.md#apple-business-manager-abm). -2. [Enroll](./Adding-hosts.md) your hosts to Fleet with [Fleetd and Fleet Desktop](https://fleetdm.com/docs/using-fleet/adding-hosts#including-fleet-desktop) -3. Ensure your end users have access to an admin account on their Mac. End users won't be able to migrate on their own if they have a standard account. -4. Migrate your hosts to Fleet in ABM: - 1. In ABM, unassign the existing hosts' MDM server from the old MDM solution: In ABM, select **Devices** and then select **All Devices**. Then, select **Edit** next to **Edit MDM Server**, select **Unassign from the current MDM**, and select **Continue**. - 2. In ABM, assign these hosts' MDM server to Fleet: In ABM, select **Devices** and then select **All Devices**. Then, select **Edit** next to **Edit MDM Server**, select **Assign to the following MDM:**, select your Fleet server in the dropdown, and select **Continue**. - -### Step 2: choose migration workflow and migrate hosts - -There are two migration workflows in Fleet: default and end user. - -The default migration workflow requires that the IT admin unenrolls hosts from the old MDM solution before the end user can complete migration. This will result in a gap in MDM coverage until the end user completes migration. - -The end user migration workflow allows the end user to kick-off migration by unenrolling from the old MDM solution on their own. Once the user is unenrolled, they're prompted to turn on MDM features in Fleet. This reduces the gap in MDM coverage. - -Configuring the end user migration workflow requires a few additional steps. - -#### Default workflow - -1. In your old MDM solution, unenroll the hosts to be migrated. MacOS does not allow multiple MDMs to be installed at once. - -2. Send [these guided instructions](#how-to-turn-on-mdm-default) to your end users to complete the final few steps via Fleet Desktop. - * Note that there will be a gap in MDM coverage between when the host is unenrolled from the old MDM and when the host turns on MDM in Fleet. - -##### End user experience - -1. The end user will receive a "Device Enrollment: <organization> can automatically configure your Mac." system notification within the macOS Notifications Center. - -2. After the end user clicks on the system notification, macOS will open the **System Setting > Profiles** and ask the user to "Allow Device Enrollment: <organization> can automatically configure your Mac based on settings provided by your System Administrator." - -3. If the end user does not install the profile, the system notification will continue to prompt the end user until the setting has been allowed. - -4. Once this setting has been approved, the MDM enrollment profile cannot be removed by the end user. - -##### How to turn on MDM (default) - -1. Select the Fleet icon in your menu bar and select **My device**. - -![Fleet icon in menu bar](https://raw.githubusercontent.com/fleetdm/fleet/main/website/assets/images/articles/fleet-desktop-says-hello-world-cover-1600x900@2x.jpg) - -2. On your **My device** page, select the **Turn on MDM** button in the yellow banner and follow the instructions. - * If you don’t see the yellow banner or the **Turn on MDM** button, select the purple **Refetch** button at the top of the page. - * If you still don't see the **Turn on MDM** button or the **My device** page presents you with an error, please contact your IT administrator. - -My device page - turn on MDM - -#### End user workflow - -> Available in Fleet Premium or Ultimate - -The end user migration workflow is supported for automatically enrolled (DEP) hosts. - -To watch a GIF that walks through the end user experience during the migration workflow, in the Fleet UI, head to **Settings > Integrations > Mobile device management (MDM)**, and scroll down to the **End user migration workflow** section. - -In Fleet, you can configure the end user workflow using the Fleet UI or fleetctl command-line tool. - -Fleet UI: - -1. Select the avatar on the right side of the top navigation and select **Settings > Integrations > Mobile device management (MDM)**. - -2. Scroll down to the **End user migration workflow** section and select the toggle to enable the workflow. - -3. Under **Mode** choose a mode and enter the webhook URL for you automation tool (ex. Tines) under **Webhook URL** and select **Save**. - -4. During the end user migration workflow, an end user's device will have their selected system theme (light or dark) applied. If your logo is not easy to see on both light and dark backgrounds, you can optionally set a logo for each theme: -Head to **Settings** > **Organization settings** > -**Organization info**, add URLs to your logos in the **Organization avatar URL (for dark backgrounds)** and **Organization avatar URL (for light backgrounds)** fields, and select **Save**. - -fleetctl CLI: - -1. Create `fleet-config.yaml` file or add to your existing `config` YAML file: - -```yaml -apiVersion: v1 -kind: config -spec: - mdm: - macos_migration: - enable: true - mode: "voluntary" - webhook_url: "https://example.com" - ... -``` - -2. Fill in the above keys under the `mdm.macos_migration` key. - -To learn about each option, in the Fleet UI, select the avatar on the right side of the top navigation, select **Settings > Integrations > Mobile device management (MDM)**, and scroll down to the **End user migration workflow** section. - -3. During the end user migration workflow, the window will show the Fleet logo on top of a dark and light background (appearance configured by end user). - -If want to add a your organization's logo, you can optionally set a logo for each background: - -```yaml -apiVersion: v1 -kind: config -spec: - org_info: - org_logo_url: https://fleetdm.com/images/press-kit/fleet-blue-logo.png - org_logo_url_light_background: https://fleetdm.com/images/press-kit/fleet-white-logo.png - ... -``` - -Add URLs to your logos that are visible on a dark background and light background in the `org_logo_url` and `org_logo_url_light_background` keys respectively. If you only set a logo for one, the Fleet logo will be used for the other. - -4. Run the fleetctl `apply -f fleet-config.yml` command to add your configuration. - -5. Confirm that your configuration was saved by running `fleetctl get config`. - -6. Send [these guided instructions](#how-to-turn-on-mdm-end-user) to your end users to complete the final few steps via Fleet Desktop. - -##### How to turn on MDM (end user) - -1. Select the Fleet icon in your menu bar and select **Migrate to Fleet**. - -2. Select **Start** in the **Migrate to Fleet** popup. - -2. On your **My device** page, select the **Turn on MDM** button in the yellow banner and follow the instructions. - * If you don’t see the yellow banner or the **Turn on MDM** button, select the purple **Refetch** button at the top of the page. - * If you still don't see the **Turn on MDM** button or the **My device** page presents you with an error, please contact your IT administrator. - -## Check migration progress - -To see a report of which hosts have successfully migrated to Fleet, have MDM features off, or are still enrolled to your old MDM solution head to the **Dashboard** page by clicking the icon on the left side of the top navigation bar. - -Then, scroll down to the **Mobile device management (MDM)** section. - -## FileVault recovery keys - -_Available in Fleet Premium_ - -When migrating from a previous MDM, end users need to take action to escrow FileVault keys to Fleet. The **My device** page in Fleet Desktop will present users with instructions to reset their key. - -To start, enforce FileVault (disk encryption) and escrow in Fleet. Learn how [here](./MDM-disk-encryption.md). - -After turning on disk encryption in Fleet, share [these guided instructions](#how-to-turn-on-disk-encryption) with your end users. - -If your old MDM solution did not enforce disk encryption, the end user will need to restart or log out of the host. - -If your old MDM solution did enforce disk encryption, the end user will need to reset their disk encryption key by following the prompt on the My device page and inputting their password. - -## Activation Lock Bypass codes - -In Fleet, the [Activation Lock](https://support.apple.com/en-us/HT208987) feature is disabled by default for automatically enrolled (DEP) hosts. - -If a host under the old MDM solution has Activation Lock enabled, we recommend asking the end user to follow these instructions to disable Activation Lock before migrating this host to Fleet: https://support.apple.com/en-us/HT208987. - -This is because if the Activation Lock is enabled, you will need the Activation Lock bypass code to successfully wipe and reuse the Mac. - -However, Activation Lock bypass codes can only be retrieved from the Mac up to 30 days after the device is enrolled. This means that when migrating from your old MDM solution, it’s likely that you’ll be unable to retrieve the Activation Lock bypass code. - -### How to turn on disk encryption - -1. Select the Fleet icon in your menu bar and select **My device**. - -![Fleet icon in menu bar](https://raw.githubusercontent.com/fleetdm/fleet/main/website/assets/images/articles/fleet-desktop-says-hello-world-cover-1600x900@2x.jpg) - -2. On your **My device** page, follow the disk encryption instructions in the yellow banner. - - If you don’t see the yellow banner, select the purple **Refetch** button at the top of the page. - - If you still don't see the yellow banner after a couple minutes or if the **My device** page presents you with an error, please contact your IT administrator. - -My device page - turn on disk encryption - - - - - diff --git a/docs/Using Fleet/MDM-setup.md b/docs/Using Fleet/MDM-setup.md deleted file mode 100644 index c56ddeb5d6..0000000000 --- a/docs/Using Fleet/MDM-setup.md +++ /dev/null @@ -1,42 +0,0 @@ -# Setup - -To turn on macOS, iOS, and iPadOS MDM features, follow the instructions on this page to connect Fleet to Apple Push Notification service (APNs). - -To use automatic enrollment (aka zero-touch) features on macOS, iOS, and iPadOS, follow instructions to connect Fleet with Apple Business Manager (ABM). - -To turn on Windows MDM features, head to this [Windows MDM setup article](https://fleetdm.com/guides/windows-mdm-setup). - -## Apple Push Notification service (APNs) - -Apple uses APNs to authenticate and manage interactions between Fleet and hosts. - -To connect Fleet to APNs or renew APNs, head to the **Settings > Integrations > Mobile device management (MDM)** page. - -> Apple requires that APNs certificates are renewed annually. -> - If your certificate expires, you will have to turn MDM off and back on for all macOS hosts. -> - Be sure to use the same Apple ID from year-to-year. If you don't, you will have to turn MDM off and back on for all macOS hosts. - -## Apple Business Manager (ABM) - -> Available in Fleet Premium - -To connect Fleet to ABM or renew ABM, head to the **Settings > Integrations > Automatic enrollment > Apple Business Manager** page. - -After connecting Fleet to ABM, set Fleet to be the MDM for all Macs: - -1. Log in to [Apple Business Manager](https://business.apple.com) -2. Click your profile icon in the bottom left -3. Click **Preferences** -4. Click **MDM Server Assignment** and click **Edit** next to **Default Server Assignment**. -5. Switch **Mac**, **iPhone**, and **iPad** to Fleet. - -New or wiped macOS, iOS, and iPadOS hosts that are in ABM, before they've been set up, appear in Fleet with **MDM status** set to "Pending". - -All macOS 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. In the Fleet UI, you can do this under **Settings** > **Teams**. - - - - - diff --git a/docs/Using Fleet/Supported-browsers.md b/docs/Using Fleet/Supported-browsers.md deleted file mode 100644 index 0ed5a0dc0e..0000000000 --- a/docs/Using Fleet/Supported-browsers.md +++ /dev/null @@ -1,28 +0,0 @@ - -# Supported browsers - -Fleet supports the latest, stable releases of all major browsers and platforms. - -We test each browser on Windows whenever possible, because our engineering team primarily uses macOS. - -**Note:** This information also applies to [fleetdm.com](https://www.fleetdm.com). - -### Desktop - -- Chrome -- Firefox -- Edge -- Safari (macOS only) - -### Mobile - -- Mobile Safari on iOS -- Mobile Chrome on Android - -### Note -> - Mobile web is not yet supported in the Fleet product. -> - The Fleet user interface [may not be fully supported](https://github.com/fleetdm/fleet/issues/969) in Google Chrome when the browser is running on ChromeOS - - - - diff --git a/docs/Using Fleet/Supported-host-operating-systems.md b/docs/Using Fleet/Supported-host-operating-systems.md deleted file mode 100644 index ff8cd5480c..0000000000 --- a/docs/Using Fleet/Supported-host-operating-systems.md +++ /dev/null @@ -1,37 +0,0 @@ -# Supported host operating systems - -Fleet supports the following operating system versions on hosts. - -| OS | Supported version(s) | -| :------ | :------------------------------------- | -| macOS | 13+ (Ventura) | -| Windows | Pro and Enterprise 10+, Server 2012+ | -| Linux | CentOS 7.1+, Ubuntu 20.04+, Fedora 38+ | -| ChromeOS | 112.0.5615.134+ | - -While Fleet may still function partially or fully with OS versions older than those above, Fleet does not actively test against unsupported versions and does not pursue bugs on them. - -## Some notes on compatibility - -### Tables -Not all osquery tables are available for every OS. Please check out the [osquery schema](https://fleetdm.com/tables) for detailed information. - -If a table is not available for your host, Fleet will generally handle things behind the scenes for you. - -### M1 Macs -Fleet's agent (fleetd) generated for MacOS by `fleetctl package` does not include native support for M1 Macs. Some values returned may reflect the information returned by Rosetta rather than the system. For example, a CPU will show up as `i486`. - -### Linux - -> Ubuntu Linux: -> Fleet Desktop currently supports Xorg as X11 server, Wayland is currently not supported. -> Ubuntu 24.04 comes with Wayland enabled by default. To use X11 instead of Wayland you can set -> `WaylandEnable=false` in `/etc/gdm3/custom.conf` and reboot. - -> Fedora, CentOS 8 and 9 require a [gnome extension](https://extensions.gnome.org/extension/615/appindicator-support/) and Google Chrome for Fleet Desktop. - -> The `fleetctl package` command is not supported on DISA-STIG distribution. - - - - diff --git a/docs/files/2024-06-14-fleet-penetration-test.pdf b/docs/files/2024-06-14-fleet-penetration-test.pdf new file mode 100644 index 0000000000..1be4c46541 Binary files /dev/null and b/docs/files/2024-06-14-fleet-penetration-test.pdf differ diff --git a/ee/bulk-operations-dashboard/.editorconfig b/ee/bulk-operations-dashboard/.editorconfig new file mode 100644 index 0000000000..6d7fa7039c --- /dev/null +++ b/ee/bulk-operations-dashboard/.editorconfig @@ -0,0 +1,31 @@ +################################################ +# ╔═╗╔╦╗╦╔╦╗╔═╗╦═╗┌─┐┌─┐┌┐┌┌─┐┬┌─┐ +# ║╣ ║║║ ║ ║ ║╠╦╝│ │ ││││├┤ ││ ┬ +# o╚═╝═╩╝╩ ╩ ╚═╝╩╚═└─┘└─┘┘└┘└ ┴└─┘ +# +# > Formatting conventions for your Sails app. +# +# This file (`.editorconfig`) exists to help +# maintain consistent formatting throughout the +# files in your Sails app. +# +# For the sake of convention, the Sails team's +# preferred settings are included here out of the +# box. You can also change this file to fit your +# team's preferences (for example, if all of the +# developers on your team have a strong preference +# for tabs over spaces), +# +# To review what each of these options mean, see: +# http://editorconfig.org/ +# +################################################ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/ee/bulk-operations-dashboard/.eslintignore b/ee/bulk-operations-dashboard/.eslintignore new file mode 100644 index 0000000000..f190c2ae4f --- /dev/null +++ b/ee/bulk-operations-dashboard/.eslintignore @@ -0,0 +1,3 @@ +assets/dependencies/**/*.js +views/**/*.ejs + diff --git a/ee/bulk-operations-dashboard/.eslintrc b/ee/bulk-operations-dashboard/.eslintrc new file mode 100644 index 0000000000..37e02b3724 --- /dev/null +++ b/ee/bulk-operations-dashboard/.eslintrc @@ -0,0 +1,92 @@ +{ + // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ + // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ + // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ + // A set of basic code conventions designed to encourage quality and consistency + // across your Sails app's code base. These rules are checked against + // automatically any time you run `npm test`. + // + // > An additional eslintrc override file is included in the `assets/` folder + // > right out of the box. This is specifically to allow for variations in acceptable + // > global variables between front-end JavaScript code designed to run in the browser + // > vs. backend code designed to run in a Node.js/Sails process. + // + // > Note: If you're using mocha, you'll want to add an extra override file to your + // > `test/` folder so that eslint will tolerate mocha-specific globals like `before` + // > and `describe`. + // Designed for ESLint v4. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // For more information about any of the rules below, check out the relevant + // reference page on eslint.org. For example, to get details on "no-sequences", + // you would visit `http://eslint.org/docs/rules/no-sequences`. If you're unsure + // or could use some advice, come by https://sailsjs.com/support. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + "env": { + "node": true + }, + + "parserOptions": { + "ecmaVersion": 2018 + }, + + "globals": { + // If "no-undef" is enabled below, be sure to list all global variables that + // are used in this app's backend code (including the globalIds of models): + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "Promise": true, + "sails": true, + "_": true, + + // Models: + "User": true, + "UndeployedProfile": true, + "UndeployedScript": true + + // …and any others. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }, + + "rules": { + "block-scoped-var": ["error"], + "callback-return": ["error", ["done", "proceed", "next", "onwards", "callback", "cb"]], + "camelcase": ["warn", {"properties":"always"}], + "comma-style": ["warn", "last"], + "curly": ["warn"], + "eqeqeq": ["error", "always"], + "eol-last": ["warn"], + "handle-callback-err": ["error"], + "indent": ["warn", 2, { + "SwitchCase": 1, + "MemberExpression": "off", + "FunctionDeclaration": {"body":1, "parameters":"off"}, + "FunctionExpression": {"body":1, "parameters":"off"}, + "CallExpression": {"arguments":"off"}, + "ArrayExpression": 1, + "ObjectExpression": 1, + "ignoredNodes": ["ConditionalExpression"] + }], + "linebreak-style": ["error", "unix"], + "no-dupe-keys": ["error"], + "no-duplicate-case": ["error"], + "no-extra-semi": ["warn"], + "no-labels": ["error"], + "no-mixed-spaces-and-tabs": [2, "smart-tabs"], + "no-redeclare": ["warn"], + "no-return-assign": ["error", "always"], + "no-sequences": ["error"], + "no-trailing-spaces": ["warn"], + "no-undef": ["error"], + "no-unexpected-multiline": ["warn"], + "no-unreachable": ["warn"], + "no-unused-vars": ["warn", {"caughtErrors":"all", "caughtErrorsIgnorePattern": "^unused($|[A-Z].*$)", "argsIgnorePattern": "^unused($|[A-Z].*$)", "varsIgnorePattern": "^unused($|[A-Z].*$)" }], + "no-use-before-define": ["error", {"functions":false}], + "one-var": ["warn", "never"], + "prefer-arrow-callback": ["warn", {"allowNamedFunctions":true}], + "quotes": ["warn", "single", {"avoidEscape":false, "allowTemplateLiterals":true}], + "semi": ["warn", "always"], + "semi-spacing": ["warn", {"before":false, "after":true}], + "semi-style": ["warn", "last"] + } + +} diff --git a/ee/bulk-operations-dashboard/.gitignore b/ee/bulk-operations-dashboard/.gitignore new file mode 100644 index 0000000000..a1ddb80e79 --- /dev/null +++ b/ee/bulk-operations-dashboard/.gitignore @@ -0,0 +1,134 @@ +################################################ +# ┌─┐┬┌┬┐╦╔═╗╔╗╔╔═╗╦═╗╔═╗ +# │ ┬│ │ ║║ ╦║║║║ ║╠╦╝║╣ +# o└─┘┴ ┴ ╩╚═╝╝╚╝╚═╝╩╚═╚═╝ +# +# > Files to exclude from your app's repo. +# +# This file (`.gitignore`) is only relevant if +# you are using git. +# +# It exists to signify to git that certain files +# and/or directories should be ignored for the +# purposes of version control. +# +# This keeps tmp files and sensitive credentials +# from being uploaded to your repository. And +# it allows you to configure your app for your +# machine without accidentally committing settings +# which will smash the local settings of other +# developers on your team. +# +# Some reasonable defaults are included below, +# but, of course, you should modify/extend/prune +# to fit your needs! +# +################################################ + + +################################################ +# Local Configuration +# +# Explicitly ignore files which contain: +# +# 1. Sensitive information you'd rather not push to +# your git repository. +# e.g., your personal API keys or passwords. +# +# 2. Developer-specific configuration +# Basically, anything that would be annoying +# to have to change every time you do a +# `git pull` on your laptop. +# e.g. your local development database, or +# the S3 bucket you're using for file uploads +# during development. +# +################################################ + +config/local.js + + +################################################ +# Dependencies +# +# +# When releasing a production app, you _could_ +# hypothetically include your node_modules folder +# in your git repo, but during development, it +# is always best to exclude it, since different +# developers may be working on different kernels, +# where dependencies would need to be recompiled +# anyway. +# +# Most of the time, the node_modules folder can +# be excluded from your code repository, even +# in production, thanks to features like the +# package-lock.json file / NPM shrinkwrap. +# +# But no matter what, since this is a Sails app, +# you should always push up the package-lock.json +# or shrinkwrap file to your repository, to avoid +# accidentally pulling in upgraded dependencies +# and breaking your code. +# +# That said, if you are having trouble with +# dependencies, (particularly when using +# `npm link`) this can be pretty discouraging. +# But rather than just adding the lockfile to +# your .gitignore, try this first: +# ``` +# rm -rf node_modules +# rm package-lock.json +# npm install +# ``` +# +# [?] For more tips/advice, come by and say hi +# over at https://sailsjs.com/support +# +################################################ + +node_modules + + +################################################ +# +# > Do you use bower? +# > re: the bower_components dir, see this: +# > http://addyosmani.com/blog/checking-in-front-end-dependencies/ +# > (credit Addy Osmani, @addyosmani) +# +################################################ + + +################################################ +# Temporary files generated by Sails/Waterline. +################################################ + +.tmp + + +################################################ +# Miscellaneous +# +# Common files generated by text editors, +# operating systems, file systems, dbs, etc. +################################################ + +*~ +*# +.DS_STORE +.netbeans +nbproject +.idea +*.iml +.vscode +.node_history +dump.rdb + +npm-debug.log +lib-cov +*.seed +*.log +*.out +*.pid + diff --git a/ee/bulk-operations-dashboard/.htmlhintrc b/ee/bulk-operations-dashboard/.htmlhintrc new file mode 100644 index 0000000000..c9b2ee72b6 --- /dev/null +++ b/ee/bulk-operations-dashboard/.htmlhintrc @@ -0,0 +1,27 @@ +{ + "alt-require": true, + "attr-lowercase": ["viewBox"], + "attr-no-duplication": true, + "attr-unsafe-chars": true, + "attr-value-double-quotes": true, + "attr-value-not-empty": false, + "csslint": false, + "doctype-first": false, + "doctype-html5": true, + "head-script-disabled": false, + "href-abs-or-rel": false, + "id-class-ad-disabled": true, + "id-class-value": false, + "id-unique": true, + "inline-script-disabled": true, + "inline-style-disabled": false, + "jshint": false, + "space-tab-mixed-disabled": "space", + "spec-char-escape": false, + "src-not-empty": true, + "style-disabled": false, + "tag-pair": true, + "tag-self-close": false, + "tagname-lowercase": true, + "title-require": false +} diff --git a/ee/bulk-operations-dashboard/.lesshintrc b/ee/bulk-operations-dashboard/.lesshintrc new file mode 100644 index 0000000000..6dcb60f278 --- /dev/null +++ b/ee/bulk-operations-dashboard/.lesshintrc @@ -0,0 +1,46 @@ +{ + // ╦ ╔═╗╔═╗╔═╗╦ ╦╦╔╗╔╔╦╗┬─┐┌─┐ + // ║ ║╣ ╚═╗╚═╗╠═╣║║║║ ║ ├┬┘│ + // o╩═╝╚═╝╚═╝╚═╝╩ ╩╩╝╚╝ ╩ ┴└─└─┘ + // Configuration designed for the lesshint linter. Describes a loose set of LESS + // conventions that help avoid typos, unexpected failed builds, and hard-to-debug + // selector and CSS rule issues. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // For more information about any of the rules below, check out the reference page + // of all rules at https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md + // If you're unsure or could use some advice, come by https://sailsjs.com/support. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "singleLinePerSelector": false, + "singleLinePerProperty": false, + "zeroUnit": false, + "idSelector": false, + "propertyOrdering": false, + "spaceAroundBang": false, + "fileExtensions": [".less", ".css"], + "excludedFiles": ["vendor.less"], + "importPath": false, + "borderZero": false, + "hexLength": false, + "hexNotation": false, + "newlineAfterBlock": false, + "spaceBeforeBrace": { + "style": "one_space" + }, + "spaceAfterPropertyName": false, + "spaceAfterPropertyColon": { + "enabled": true, + "style": "one_space" + }, + "maxCharPerLine": false, + "emptyRule": false, + "importantRule": true, + "qualifyingElement": false + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // ^^ This last one is only disabled because the lesshint parser seems to have + // a hard time distinguishing between things like `div.bar` and `&.bar`. + // In this case, the ampersand has a distinct meaning, and it does not refer + // to an element. (It's referring to the case where that class is matched at + // the parent level, rather than talking about a descendant.) + // https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md#qualifyingelement + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +} diff --git a/ee/bulk-operations-dashboard/.npmrc b/ee/bulk-operations-dashboard/.npmrc new file mode 100644 index 0000000000..43601ce8db --- /dev/null +++ b/ee/bulk-operations-dashboard/.npmrc @@ -0,0 +1,11 @@ +###################### +# ╔╗╔╔═╗╔╦╗┬─┐┌─┐ # +# ║║║╠═╝║║║├┬┘│ # +# o╝╚╝╩ ╩ ╩┴└─└─┘ # +###################### + +# Hide NPM log output unless it is related to an error of some kind: +loglevel=error + +# Make "npm audit" an opt-in thing for subsequent installs within this app: +audit=false diff --git a/ee/bulk-operations-dashboard/.sailsrc b/ee/bulk-operations-dashboard/.sailsrc new file mode 100644 index 0000000000..782d995af6 --- /dev/null +++ b/ee/bulk-operations-dashboard/.sailsrc @@ -0,0 +1,12 @@ +{ + "hooks": { + "sockets": false + }, + "generators": { + "modules": {} + }, + "_generatedWith": { + "sails": "1.5.11", + "sails-generate": "2.0.11" + } +} diff --git a/ee/bulk-operations-dashboard/Dockerfile b/ee/bulk-operations-dashboard/Dockerfile new file mode 100644 index 0000000000..c68b6f335a --- /dev/null +++ b/ee/bulk-operations-dashboard/Dockerfile @@ -0,0 +1,26 @@ +# Use the official Node.js 14 image as a base +FROM node:20@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5 + +# Set the working directory in the container +WORKDIR /usr/src/app + +# Copy the package.json +COPY package.json ./ + +# Install vulnerability dashboard dependencies +RUN npm install + +# Copy the vulnerability dashboard into the container +COPY . . + +# Copy the entrypoint script into the container +COPY entrypoint.sh /usr/src/app/entrypoint.sh + +# Make sure the entrypoint script is executable +RUN chmod +x /usr/src/app/entrypoint.sh + +# Expose the port the vulnerability dashboard runs on +EXPOSE 1337 + +# Set the entrypoint script as the entry point +ENTRYPOINT ["/usr/src/app/entrypoint.sh"] diff --git a/ee/bulk-operations-dashboard/Gruntfile.js b/ee/bulk-operations-dashboard/Gruntfile.js new file mode 100644 index 0000000000..e3b2847338 --- /dev/null +++ b/ee/bulk-operations-dashboard/Gruntfile.js @@ -0,0 +1,23 @@ +/** + * Gruntfile + * + * This Node script is executed when you run `grunt`-- and also when + * you run `sails lift` (provided the grunt hook is installed and + * hasn't been disabled). + * + * WARNING: + * Unless you know what you're doing, you shouldn't change this file. + * Check out the `tasks/` directory instead. + * + * For more information see: + * https://sailsjs.com/anatomy/Gruntfile.js + */ +module.exports = function(grunt) { + + var loadGruntTasks = require('sails-hook-grunt/accessible/load-grunt-tasks'); + + // Load Grunt task configurations (from `tasks/config/`) and Grunt + // task registrations (from `tasks/register/`). + loadGruntTasks(__dirname, grunt); + +}; diff --git a/ee/bulk-operations-dashboard/README.md b/ee/bulk-operations-dashboard/README.md new file mode 100644 index 0000000000..678396b623 --- /dev/null +++ b/ee/bulk-operations-dashboard/README.md @@ -0,0 +1,49 @@ +# Bulk operations dashboard + + +A dashboard to easily manage profiles and scripts across multiple teams on a Fleet instance. + + +## Dependencies + +- A datastore, this app was built using Postgres, but you can use a database of your choice. + +- A Redis database - For session storage. + + +## Configuration + +This app has two required custom configuration values: + +- `sails.config.custom.fleetBaseUrl`: The full URL of your Fleet instance. (e.g., https://fleet.example.com) + +- `sails.config.custom.fleetApiToken`: An API token for an API-only user on your Fleet instance. + + + +## Running the bulk operations dashboard with Docker. + +To run a local bulk operations dashboard with docker, you can follow these instructions. + +1. Clone this repo +2. Update the following ENV variables `ee/bulk-operations-dashboard/docker-compose.yml` file: + + 1. `sails_custom__fleetBaseUrl`: The full URL of your Fleet instance. (e.g., https://fleet.example.com) + + 2. `sails_custom__fleetApiToken`: An API token for an API-only user on your Fleet instance. + + >You can read about how to create an API-only user and get it's token [here](https://fleetdm.com/docs/using-fleet/fleetctl-cli#create-api-only-user) + +3. Open the `ee/bulk-operations-dashboard/` folder in your terminal. + +4. Run `docker compose up --build` to build the bulk operations dashboard's Docker image. + + > The first time the bulk operations dashboard starts it will Initalize the database aby running the `config/bootstrap.js` script before the server starts. + +5. Once the container is done building, the bulk operations dashboard will be available at http://localhost:1337 + + > You can login with the default admin login: + > + >- Email address: `admin@example.com` + > + >- Password: `abc123` diff --git a/ee/bulk-operations-dashboard/api/controllers/account/logout.js b/ee/bulk-operations-dashboard/api/controllers/account/logout.js new file mode 100644 index 0000000000..61e50d8c19 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/logout.js @@ -0,0 +1,55 @@ +module.exports = { + + + friendlyName: 'Logout', + + + description: 'Log out of this app.', + + + extendedDescription: +`This action deletes the \`req.session.userId\` key from the session of the requesting user agent. +Actual garbage collection of session data depends on this app's session store, and +potentially also on the [TTL configuration](https://sailsjs.com/docs/reference/configuration/sails-config-session) +you provided for it. + +Note that this action does not check to see whether or not the requesting user was +actually logged in. (If they weren't, then this action is just a no-op.)`, + + + exits: { + + success: { + description: 'The requesting user agent has been successfully logged out.' + }, + + redirect: { + description: 'The requesting user agent looks to be a web browser.', + extendedDescription: 'After logging out from a web browser, the user is redirected away.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + // Clear the `userId` property from this session. + delete this.req.session.userId; + + // Broadcast a message that we can display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + // Then finish up, sending an appropriate response. + // > Under the covers, this persists the now-logged-out session back + // > to the underlying session store. + if (!this.req.wantsJSON) { + throw {redirect: '/login'}; + } + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/update-billing-card.js b/ee/bulk-operations-dashboard/api/controllers/account/update-billing-card.js new file mode 100644 index 0000000000..7cbeac5bab --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/update-billing-card.js @@ -0,0 +1,79 @@ +module.exports = { + + + friendlyName: 'Update billing card', + + + description: 'Update the credit card for the logged-in user.', + + + inputs: { + + stripeToken: { + type: 'string', + example: 'tok_199k3qEXw14QdSnRwmsK99MH', + description: 'The single-use Stripe Checkout token identifier representing the user\'s payment source (i.e. credit card.)', + extendedDescription: 'Omit this (or use "") to remove this user\'s payment source.', + whereToGet: { + description: 'This Stripe.js token is provided to the front-end (client-side) code after completing a Stripe Checkout or Stripe Elements flow.' + } + }, + + billingCardLast4: { + type: 'string', + example: '4242', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardBrand: { + type: 'string', + example: 'visa', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardExpMonth: { + type: 'string', + example: '08', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + billingCardExpYear: { + type: 'string', + example: '2023', + description: 'Omit if removing card info.', + whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' } + }, + + }, + + + fn: async function ({stripeToken, billingCardLast4, billingCardBrand, billingCardExpMonth, billingCardExpYear}) { + + // Add, update, or remove the default payment source for the logged-in user's + // customer entry in Stripe. + var stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: this.req.me.stripeCustomerId, + token: stripeToken || '', + }).timeout(5000).retry(); + + // Update (or clear) the card info we have stored for this user in our database. + // > Remember, never store complete card numbers-- only the last 4 digits + expiration! + // > Storing (or even receiving) complete, unencrypted card numbers would require PCI + // > compliance in the U.S. + await User.updateOne({ id: this.req.me.id }) + .set({ + stripeCustomerId, + hasBillingCard: stripeToken ? true : false, + billingCardBrand: stripeToken ? billingCardBrand : '', + billingCardLast4: stripeToken ? billingCardLast4 : '', + billingCardExpMonth: stripeToken ? billingCardExpMonth : '', + billingCardExpYear: stripeToken ? billingCardExpYear : '' + }); + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/update-password.js b/ee/bulk-operations-dashboard/api/controllers/account/update-password.js new file mode 100644 index 0000000000..00a53a38d6 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/update-password.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'Update password', + + + description: 'Update the password for the logged-in user.', + + + inputs: { + + password: { + description: 'The new, unencrypted password.', + example: 'abc123v2', + required: true + } + + }, + + + fn: async function ({password}) { + + // Hash the new password. + var hashed = await sails.helpers.passwords.hashPassword(password); + + // Update the record for the logged-in user. + await User.updateOne({ id: this.req.me.id }) + .set({ + password: hashed + }); + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/update-profile.js b/ee/bulk-operations-dashboard/api/controllers/account/update-profile.js new file mode 100644 index 0000000000..afc5fd1b54 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/update-profile.js @@ -0,0 +1,160 @@ +module.exports = { + + + friendlyName: 'Update profile', + + + description: 'Update the profile for the logged-in user.', + + + inputs: { + + fullName: { + type: 'string' + }, + + emailAddress: { + type: 'string' + }, + + }, + + + exits: { + + emailAlreadyInUse: { + statusCode: 409, + description: 'The provided email address is already in use.', + }, + + }, + + + fn: async function ({fullName, emailAddress}) { + + var newEmailAddress = emailAddress; + if (newEmailAddress !== undefined) { + newEmailAddress = newEmailAddress.toLowerCase(); + } + + // Determine if this request wants to change the current user's email address, + // revert her pending email address change, modify her pending email address + // change, or if the email address won't be affected at all. + var desiredEmailEffect;// ('change-immediately', 'begin-change', 'cancel-pending-change', 'modify-pending-change', or '') + if ( + newEmailAddress === undefined || + (this.req.me.emailStatus !== 'change-requested' && newEmailAddress === this.req.me.emailAddress) || + (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailChangeCandidate) + ) { + desiredEmailEffect = ''; + } else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailAddress) { + desiredEmailEffect = 'cancel-pending-change'; + } else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress !== this.req.me.emailAddress) { + desiredEmailEffect = 'modify-pending-change'; + } else if (!sails.config.custom.verifyEmailAddresses || this.req.me.emailStatus === 'unconfirmed') { + desiredEmailEffect = 'change-immediately'; + } else { + desiredEmailEffect = 'begin-change'; + } + + + // If the email address is changing, make sure it is not already being used. + if (_.contains(['begin-change', 'change-immediately', 'modify-pending-change'], desiredEmailEffect)) { + let conflictingUser = await User.findOne({ + or: [ + { emailAddress: newEmailAddress }, + { emailChangeCandidate: newEmailAddress } + ] + }); + if (conflictingUser) { + throw 'emailAlreadyInUse'; + } + } + + + // Start building the values to set in the db. + // (We always set the fullName if provided.) + var valuesToSet = { + fullName, + }; + + switch (desiredEmailEffect) { + + // Change now + case 'change-immediately': + _.extend(valuesToSet, { + emailAddress: newEmailAddress, + emailChangeCandidate: '', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailStatus: this.req.me.emailStatus === 'unconfirmed' ? 'unconfirmed' : 'confirmed' + }); + break; + + // Begin new email change, or modify a pending email change + case 'begin-change': + case 'modify-pending-change': + _.extend(valuesToSet, { + emailChangeCandidate: newEmailAddress, + emailProofToken: await sails.helpers.strings.random('url-friendly'), + emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL, + emailStatus: 'change-requested' + }); + break; + + // Cancel pending email change + case 'cancel-pending-change': + _.extend(valuesToSet, { + emailChangeCandidate: '', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailStatus: 'confirmed' + }); + break; + + // Otherwise, do nothing re: email + } + + // Save to the db + await User.updateOne({id: this.req.me.id }) + .set(valuesToSet); + + // If this is an immediate change, and billing features are enabled, + // then also update the billing email for this user's linked customer entry + // in the Stripe API to make sure they receive email receipts. + // > Note: If there was not already a Stripe customer entry for this user, + // > then one will be set up implicitly, so we'll need to persist it to our + // > database. (This could happen if Stripe credentials were not configured + // > at the time this user was originally created.) + if(desiredEmailEffect === 'change-immediately' && sails.config.custom.enableBillingFeatures) { + let didNotAlreadyHaveCustomerId = (! this.req.me.stripeCustomerId); + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: this.req.me.stripeCustomerId, + emailAddress: newEmailAddress + }).timeout(5000).retry(); + if (didNotAlreadyHaveCustomerId){ + await User.updateOne({ id: this.req.me.id }) + .set({ + stripeCustomerId + }); + } + } + + // If an email address change was requested, and re-confirmation is required, + // send the "confirm account" email. + if (desiredEmailEffect === 'begin-change' || desiredEmailEffect === 'modify-pending-change') { + await sails.helpers.sendTemplateEmail.with({ + to: newEmailAddress, + subject: 'Your account has been updated', + template: 'email-verify-new-email', + templateData: { + fullName: fullName||this.req.me.fullName, + token: valuesToSet.emailProofToken + } + }); + } + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js b/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js new file mode 100644 index 0000000000..f5841f9981 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/view-account-overview.js @@ -0,0 +1,30 @@ +module.exports = { + + + friendlyName: 'View account overview', + + + description: 'Display "Account Overview" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/account-overview', + } + + }, + + + fn: async function () { + + // If billing features are enabled, include our configured Stripe.js + // public key in the view locals. Otherwise, leave it as undefined. + return { + stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined, + }; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/view-edit-password.js b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-password.js new file mode 100644 index 0000000000..208a1d6a3f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-password.js @@ -0,0 +1,26 @@ +module.exports = { + + + friendlyName: 'View edit password', + + + description: 'Display "Edit password" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/edit-password' + } + + }, + + + fn: async function () { + + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js new file mode 100644 index 0000000000..baea0f7c20 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/account/view-edit-profile.js @@ -0,0 +1,26 @@ +module.exports = { + + + friendlyName: 'View edit profile', + + + description: 'Display "Edit profile" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/account/edit-profile', + } + + }, + + + fn: async function () { + + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/confirm-email.js b/ee/bulk-operations-dashboard/api/controllers/entrance/confirm-email.js new file mode 100644 index 0000000000..01afe04cd9 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/confirm-email.js @@ -0,0 +1,160 @@ +module.exports = { + + + friendlyName: 'Confirm email', + + + description: +`Confirm a new user's email address, or an existing user's request for an email address change, +then redirect to either a special landing page (for newly-signed up users), or the account page +(for existing users who just changed their email address).`, + + + inputs: { + + token: { + description: 'The confirmation token from the email.', + example: '4-32fad81jdaf$329' + } + + }, + + + exits: { + + success: { + description: 'Email address confirmed and requesting user logged in.' + }, + + redirect: { + description: 'Email address confirmed and requesting user logged in. Since this looks like a browser, redirecting...', + responseType: 'redirect' + }, + + invalidOrExpiredToken: { + responseType: 'expired', + description: 'The provided token is expired, invalid, or already used up.', + }, + + emailAddressNoLongerAvailable: { + statusCode: 409, + viewTemplatePath: '500', + description: 'The email address is no longer available.', + extendedDescription: 'This is an edge case that is not always anticipated by websites and APIs. Since it is pretty rare, the 500 server error page is used as a simple catch-all. If this becomes important in the future, this could easily be expanded into a custom error page or resolution flow. But for context: this behavior of showing the 500 server error page mimics how popular apps like Slack behave under the same circumstances.', + } + + }, + + + fn: async function ({token}) { + + // If no token was provided, this is automatically invalid. + if (!token) { + throw 'invalidOrExpiredToken'; + } + + // Get the user with the matching email token. + var user = await User.findOne({ emailProofToken: token }); + + // If no such user exists, or their token is expired, bail. + if (!user || user.emailProofTokenExpiresAt <= Date.now()) { + throw 'invalidOrExpiredToken'; + } + + if (user.emailStatus === 'unconfirmed') { + // ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦╦═╗╔═╗╔╦╗ ╔╦╗╦╔╦╗╔═╗ ╦ ╦╔═╗╔═╗╦═╗ ┌─┐┌┬┐┌─┐┬┬ + // │ │ ││││├┤ │├┬┘││││││││ ┬ ╠╣ ║╠╦╝╚═╗ ║───║ ║║║║║╣ ║ ║╚═╗║╣ ╠╦╝ ├┤ │││├─┤││ + // └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚ ╩╩╚═╚═╝ ╩ ╩ ╩╩ ╩╚═╝ ╚═╝╚═╝╚═╝╩╚═ └─┘┴ ┴┴ ┴┴┴─┘ + // If this is a new user confirming their email for the first time, + // then just update the state of their user record in the database, + // store their user id in the session (just in case they aren't logged + // in already), and then redirect them to the "email confirmed" page. + await User.updateOne({ id: user.id }).set({ + emailStatus: 'confirmed', + emailProofToken: '', + emailProofTokenExpiresAt: 0 + }); + this.req.session.userId = user.id; + + // In case there was an existing session, broadcast a message that we can + // display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + if (this.req.wantsJSON) { + return; + } else { + throw { redirect: '/email/confirmed' }; + } + + } else if (user.emailStatus === 'change-requested') { + // ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦ ╦╔═╗╔╗╔╔═╗╔═╗╔╦╗ ┌─┐┌┬┐┌─┐┬┬ + // │ │ ││││├┤ │├┬┘││││││││ ┬ ║ ╠═╣╠═╣║║║║ ╦║╣ ║║ ├┤ │││├─┤││ + // └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚═╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝ └─┘┴ ┴┴ ┴┴┴─┘ + if (!user.emailChangeCandidate){ + throw new Error(`Consistency violation: Could not update Stripe customer because this user record's emailChangeCandidate ("${user.emailChangeCandidate}") is missing. (This should never happen.)`); + } + + // Last line of defense: since email change candidates are not protected + // by a uniqueness constraint in the database, it's important that we make + // sure no one else managed to grab this email in the mean time since we + // last checked its availability. (This is a relatively rare edge case-- + // see exit description.) + if (await User.count({ emailAddress: user.emailChangeCandidate }) > 0) { + throw 'emailAddressNoLongerAvailable'; + } + + // If billing features are enabled, also update the billing email for this + // user's linked customer entry in the Stripe API to make sure they receive + // email receipts. + // > Note: If there was not already a Stripe customer entry for this user, + // > then one will be set up implicitly, so we'll need to persist it to our + // > database. (This could happen if Stripe credentials were not configured + // > at the time this user was originally created.) + if(sails.config.custom.enableBillingFeatures) { + let didNotAlreadyHaveCustomerId = (! user.stripeCustomerId); + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + stripeCustomerId: user.stripeCustomerId, + emailAddress: user.emailChangeCandidate + }).timeout(5000).retry(); + if (didNotAlreadyHaveCustomerId){ + await User.updateOne({ id: user.id }).set({ + stripeCustomerId + }); + } + } + + // Finally update the user in the database, store their id in the session + // (just in case they aren't logged in already), then redirect them to + // their "my account" page so they can see their updated email address. + await User.updateOne({ id: user.id }) + .set({ + emailStatus: 'confirmed', + emailProofToken: '', + emailProofTokenExpiresAt: 0, + emailAddress: user.emailChangeCandidate, + emailChangeCandidate: '', + }); + this.req.session.userId = user.id; + + // In case there was an existing session, broadcast a message that we can + // display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + if (this.req.wantsJSON) { + return; + } else { + throw { redirect: '/account' }; + } + + } else { + throw new Error(`Consistency violation: User ${user.id} has an email proof token, but somehow also has an emailStatus of "${user.emailStatus}"! (This should never happen.)`); + } + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/login.js b/ee/bulk-operations-dashboard/api/controllers/entrance/login.js new file mode 100644 index 0000000000..a95aabf84c --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/login.js @@ -0,0 +1,119 @@ +module.exports = { + + + friendlyName: 'Login', + + + description: 'Log in using the provided email and password combination.', + + + extendedDescription: +`This action attempts to look up the user record in the database with the +specified email address. Then, if such a user exists, it uses +bcrypt to compare the hashed password from the database with the provided +password attempt.`, + + + inputs: { + + emailAddress: { + description: 'The email to try in this attempt, e.g. "irl@example.com".', + type: 'string', + required: true + }, + + password: { + description: 'The unencrypted password to try in this attempt, e.g. "passwordlol".', + type: 'string', + required: true + }, + + rememberMe: { + description: 'Whether to extend the lifetime of the user\'s session.', + extendedDescription: +`Note that this is NOT SUPPORTED when using virtual requests (e.g. sending +requests over WebSockets instead of HTTP).`, + type: 'boolean' + } + + }, + + + exits: { + + success: { + description: 'The requesting user agent has been successfully logged in.', + extendedDescription: +`Under the covers, this stores the id of the logged-in user in the session +as the \`userId\` key. The next time this user agent sends a request, assuming +it includes a cookie (like a web browser), Sails will automatically make this +user id available as req.session.userId in the corresponding action. (Also note +that, thanks to the included "custom" hook, when a relevant request is received +from a logged-in user, that user's entire record from the database will be fetched +and exposed as \`req.me\`.)` + }, + + badCombo: { + description: `The provided email and password combination does not + match any user in the database.`, + responseType: 'unauthorized' + // ^This uses the custom `unauthorized` response located in `api/responses/unauthorized.js`. + // To customize the generic "unauthorized" response across this entire app, change that file + // (see api/responses/unauthorized). + // + // To customize the response for _only this_ action, replace `responseType` with + // something else. For example, you might set `statusCode: 498` and change the + // implementation below accordingly (see http://sailsjs.com/docs/concepts/controllers). + } + + }, + + + fn: async function ({emailAddress, password, rememberMe}) { + + // Look up by the email address. + // (note that we lowercase it to ensure the lookup is always case-insensitive, + // regardless of which database we're using) + var userRecord = await User.findOne({ + emailAddress: emailAddress.toLowerCase(), + }); + + // If there was no matching user, respond thru the "badCombo" exit. + if(!userRecord) { + throw 'badCombo'; + } + + // If the password doesn't match, then also exit thru "badCombo". + await sails.helpers.passwords.checkPassword(password, userRecord.password) + .intercept('incorrect', 'badCombo'); + + // If "Remember Me" was enabled, then keep the session alive for + // a longer amount of time. (This causes an updated "Set Cookie" + // response header to be sent as the result of this request -- thus + // we must be dealing with a traditional HTTP request in order for + // this to work.) + if (rememberMe) { + if (this.req.isSocket) { + sails.log.warn( + 'Received `rememberMe: true` from a virtual request, but it was ignored\n'+ + 'because a browser\'s session cookie cannot be reset over sockets.\n'+ + 'Please use a traditional HTTP request instead.' + ); + } else { + this.req.session.cookie.maxAge = sails.config.custom.rememberMeCookieMaxAge; + } + }//fi + + // Modify the active session instance. + // (This will be persisted when the response is sent.) + this.req.session.userId = userRecord.id; + + // In case there was an existing session (e.g. if we allow users to go to the login page + // when they're already logged in), broadcast a message that we can display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + } + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/send-password-recovery-email.js b/ee/bulk-operations-dashboard/api/controllers/entrance/send-password-recovery-email.js new file mode 100644 index 0000000000..21d8f0efa2 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/send-password-recovery-email.js @@ -0,0 +1,66 @@ +module.exports = { + + + friendlyName: 'Send password recovery email', + + + description: 'Send a password recovery notification to the user with the specified email address.', + + + inputs: { + + emailAddress: { + description: 'The email address of the alleged user who wants to recover their password.', + example: 'rydahl@example.com', + type: 'string', + required: true + } + + }, + + + exits: { + + success: { + description: 'The email address might have matched a user in the database. (If so, a recovery email was sent.)' + }, + + }, + + + fn: async function ({emailAddress}) { + + // Find the record for this user. + // (Even if no such user exists, pretend it worked to discourage sniffing.) + var userRecord = await User.findOne({ emailAddress }); + if (!userRecord) { + return; + }//• + + // Come up with a pseudorandom, probabilistically-unique token for use + // in our password recovery email. + var token = await sails.helpers.strings.random('url-friendly'); + + // Store the token on the user record + // (This allows us to look up the user when the link from the email is clicked.) + await User.updateOne({ id: userRecord.id }) + .set({ + passwordResetToken: token, + passwordResetTokenExpiresAt: Date.now() + sails.config.custom.passwordResetTokenTTL, + }); + + // Send recovery email + await sails.helpers.sendTemplateEmail.with({ + to: emailAddress, + subject: 'Password reset instructions', + template: 'email-reset-password', + templateData: { + fullName: userRecord.fullName, + token: token + } + }); + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/signup.js b/ee/bulk-operations-dashboard/api/controllers/entrance/signup.js new file mode 100644 index 0000000000..e1fd133c59 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/signup.js @@ -0,0 +1,127 @@ +module.exports = { + + + friendlyName: 'Signup', + + + description: 'Sign up for a new user account.', + + + extendedDescription: +`This creates a new user record in the database, signs in the requesting user agent +by modifying its [session](https://sailsjs.com/documentation/concepts/sessions), and +(if emailing with Mailgun is enabled) sends an account verification email. + +If a verification email is sent, the new user's account is put in an "unconfirmed" state +until they confirm they are using a legitimate email address (by clicking the link in +the account verification message.)`, + + + inputs: { + + emailAddress: { + required: true, + type: 'string', + isEmail: true, + description: 'The email address for the new account, e.g. m@example.com.', + extendedDescription: 'Must be a valid email address.', + }, + + password: { + required: true, + type: 'string', + maxLength: 200, + example: 'passwordlol', + description: 'The unencrypted password to use for the new account.' + }, + + fullName: { + required: true, + type: 'string', + example: 'Frida Kahlo de Rivera', + description: 'The user\'s full name.', + } + + }, + + + exits: { + + success: { + description: 'New user account was created successfully.' + }, + + invalid: { + responseType: 'badRequest', + description: 'The provided fullName, password and/or email address are invalid.', + extendedDescription: 'If this request was sent from a graphical user interface, the request '+ + 'parameters should have been validated/coerced _before_ they were sent.' + }, + + emailAlreadyInUse: { + statusCode: 409, + description: 'The provided email address is already in use.', + }, + + }, + + + fn: async function ({emailAddress, password, fullName}) { + + var newEmailAddress = emailAddress.toLowerCase(); + + // Build up data for the new user record and save it to the database. + // (Also use `fetch` to retrieve the new ID so that we can use it below.) + var newUserRecord = await User.create(_.extend({ + fullName, + emailAddress: newEmailAddress, + password: await sails.helpers.passwords.hashPassword(password), + tosAcceptedByIp: this.req.ip + }, sails.config.custom.verifyEmailAddresses? { + emailProofToken: await sails.helpers.strings.random('url-friendly'), + emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL, + emailStatus: 'unconfirmed' + }:{})) + .intercept('E_UNIQUE', 'emailAlreadyInUse') + .intercept({name: 'UsageError'}, 'invalid') + .fetch(); + + // If billing feaures are enabled, save a new customer entry in the Stripe API. + // Then persist the Stripe customer id in the database. + if (sails.config.custom.enableBillingFeatures) { + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + emailAddress: newEmailAddress + }).timeout(5000).retry(); + await User.updateOne({id: newUserRecord.id}) + .set({ + stripeCustomerId + }); + } + + // Store the user's new id in their session. + this.req.session.userId = newUserRecord.id; + + // In case there was an existing session (e.g. if we allow users to go to the signup page + // when they're already logged in), broadcast a message that we can display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + if (sails.config.custom.verifyEmailAddresses) { + // Send "confirm account" email + await sails.helpers.sendTemplateEmail.with({ + to: newEmailAddress, + subject: 'Please confirm your account', + template: 'email-verify-account', + templateData: { + fullName, + token: newUserRecord.emailProofToken + } + }); + } else { + sails.log.info('Skipping new account email verification... (since `verifyEmailAddresses` is disabled)'); + } + + } + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/update-password-and-login.js b/ee/bulk-operations-dashboard/api/controllers/entrance/update-password-and-login.js new file mode 100644 index 0000000000..51a75b8746 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/update-password-and-login.js @@ -0,0 +1,80 @@ +module.exports = { + + + friendlyName: 'Update password and login', + + + description: 'Finish the password recovery flow by setting the new password and '+ + 'logging in the requesting user, based on the authenticity of their token.', + + + inputs: { + + password: { + description: 'The new, unencrypted password.', + example: 'abc123v2', + required: true + }, + + token: { + description: 'The password token that was generated by the `sendPasswordRecoveryEmail` endpoint.', + example: 'gwa8gs8hgw9h2g9hg29hgwh9asdgh9q34$$$$$asdgasdggds', + required: true + } + + }, + + + exits: { + + success: { + description: 'Password successfully updated, and requesting user agent is now logged in.' + }, + + invalidToken: { + description: 'The provided password token is invalid, expired, or has already been used.', + responseType: 'expired' + } + + }, + + + fn: async function ({password, token}) { + + if(!token) { + throw 'invalidToken'; + } + + // Look up the user with this reset token. + var userRecord = await User.findOne({ passwordResetToken: token }); + + // If no such user exists, or their token is expired, bail. + if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) { + throw 'invalidToken'; + } + + // Hash the new password. + var hashed = await sails.helpers.passwords.hashPassword(password); + + // Store the user's new password and clear their reset token so it can't be used again. + await User.updateOne({ id: userRecord.id }) + .set({ + password: hashed, + passwordResetToken: '', + passwordResetTokenExpiresAt: 0 + }); + + // Log the user in. + // (This will be persisted when the response is sent.) + this.req.session.userId = userRecord.id; + + // In case there was an existing session, broadcast a message that we can + // display in other open tabs. + if (sails.hooks.sockets) { + await sails.helpers.broadcastSessionChange(this.req); + } + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/view-confirmed-email.js b/ee/bulk-operations-dashboard/api/controllers/entrance/view-confirmed-email.js new file mode 100644 index 0000000000..0602f10e99 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/view-confirmed-email.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View confirmed email', + + + description: 'Display "Confirmed email" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/confirmed-email' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/view-forgot-password.js b/ee/bulk-operations-dashboard/api/controllers/entrance/view-forgot-password.js new file mode 100644 index 0000000000..e6b5404c9f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/view-forgot-password.js @@ -0,0 +1,36 @@ +module.exports = { + + + friendlyName: 'View forgot password', + + + description: 'Display "Forgot password" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/forgot-password', + }, + + redirect: { + description: 'The requesting user is already logged in.', + extendedDescription: 'Logged-in users should change their password in "Account settings."', + responseType: 'redirect', + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/view-login.js b/ee/bulk-operations-dashboard/api/controllers/entrance/view-login.js new file mode 100644 index 0000000000..1d1c590b53 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/view-login.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'View login', + + + description: 'Display "Login" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/login', + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/view-new-password.js b/ee/bulk-operations-dashboard/api/controllers/entrance/view-new-password.js new file mode 100644 index 0000000000..4532fa5fef --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/view-new-password.js @@ -0,0 +1,57 @@ +module.exports = { + + + friendlyName: 'View new password', + + + description: 'Display "New password" page.', + + + inputs: { + + token: { + description: 'The password reset token from the email.', + example: '4-32fad81jdaf$329' + } + + }, + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/new-password' + }, + + invalidOrExpiredToken: { + responseType: 'expired', + description: 'The provided token is expired, invalid, or has already been used.', + } + + }, + + + fn: async function ({token}) { + + // If password reset token is missing, display an error page explaining that the link is bad. + if (!token) { + sails.log.warn('Attempting to view new password (recovery) page, but no reset password token included in request! Displaying error page...'); + throw 'invalidOrExpiredToken'; + }//• + + // Look up the user with this reset token. + var userRecord = await User.findOne({ passwordResetToken: token }); + // If no such user exists, or their token is expired, display an error page explaining that the link is bad. + if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) { + throw 'invalidOrExpiredToken'; + } + + // Grab token and include it in view locals + return { + token, + }; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/entrance/view-signup.js b/ee/bulk-operations-dashboard/api/controllers/entrance/view-signup.js new file mode 100644 index 0000000000..be43753770 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/entrance/view-signup.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'View signup', + + + description: 'Display "Signup" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/entrance/signup', + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + if (this.req.me) { + throw {redirect: '/'}; + } + + return {}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/get-profiles.js b/ee/bulk-operations-dashboard/api/controllers/get-profiles.js new file mode 100644 index 0000000000..7326fea9ae --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/get-profiles.js @@ -0,0 +1,113 @@ +module.exports = { + + + friendlyName: 'Get profiles', + + + description: 'Builds and returns an array of deployed configuration profiles on the Fleet instance and undeployed profiles stored in the dashboard\'s datastore.', + + exits: { + success: { + outputType: [{}], + } + }, + + + fn: async function () { + // Get all teams on the Fleet instance. + let teamsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/teams', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + + let allTeams = teamsResponseData.teams; + + let teams = []; + for(let team of allTeams) { + teams.push({ + fleetApid: team.id, + teamName: team.name, + }); + } + // Add the "team" for hosts with no team + teams.push({ + fleetApid: 0, + teamName: 'No team', + }); + + + let allProfiles = []; + let teamApids = _.pluck(allTeams, 'id'); + // Get all of the configuration profiles on the Fleet instance. + for(let teamApid of teamApids){ + let configurationProfilesResponseData = await sails.helpers.http.get.with({ + url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`, + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let profilesForThisTeam = configurationProfilesResponseData.profiles; + allProfiles = allProfiles.concat(profilesForThisTeam); + } + + // Add the configurations profiles that are assigned to the "no team" team. + let noTeamConfigurationProfilesResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/configuration_profiles', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let profilesForThisTeam = noTeamConfigurationProfilesResponseData.profiles; + allProfiles = allProfiles.concat(profilesForThisTeam); + + // console.log(allProfiles); + + let profilesOnThisFleetInstance = []; + // Group configuration profiles by their identifier. + let allProfilesByIdentifier = _.groupBy(allProfiles, 'identifier'); + for(let profileIdentifier in allProfilesByIdentifier) { + // Iterate through the arrays of profiles with the same unique identifier. + let teamsForThisProfile = []; + // Add the profile's UUID and information about the team this profile is assigned to the teams array for profiles. + for(let profile of allProfilesByIdentifier[profileIdentifier]){ + let informationAboutThisProfile = { + uuid: profile.profile_uuid, + fleetApid: profile.team_id, + teamName: _.find(teams, {fleetApid: profile.team_id}).teamName, + }; + teamsForThisProfile.push(informationAboutThisProfile); + } + let profile = allProfilesByIdentifier[profileIdentifier][0];// Grab the first profile returned in the api repsonse to build our profile configuration. + let profileInformation = { + name: profile.name, + identifier: profileIdentifier, + platform: profile.platform, + createdAt: new Date(profile.created_at).getTime(), + teams: teamsForThisProfile + }; + profilesOnThisFleetInstance.push(profileInformation); + } + // Get the undeployed profiles from the app's database. + let undeployedProfiles = await UndeployedProfile.find(); + profilesOnThisFleetInstance = _.union(profilesOnThisFleetInstance, undeployedProfiles); + + // Sort profiles by their name. + profilesOnThisFleetInstance = _.sortByOrder(profilesOnThisFleetInstance, 'name', 'asc'); + + return profilesOnThisFleetInstance; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/get-scripts.js b/ee/bulk-operations-dashboard/api/controllers/get-scripts.js new file mode 100644 index 0000000000..1900b9b700 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/get-scripts.js @@ -0,0 +1,120 @@ +module.exports = { + + + friendlyName: 'Get scripts', + + + description: 'Builds and returns an array of deployed scripts on the Fleet instance and undeployed scripts stored in the dashboard\'s datastore', + + + exits: { + success: { + outputType: [{}], + } + }, + + + fn: async function () { + // Get all teams on the Fleet instance. + let teamsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/teams', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + + let allTeams = teamsResponseData.teams; + + let teamApids = _.pluck(allTeams, 'id'); + let teams = []; + for(let team of allTeams) { + teams.push({ + fleetApid: team.id, + teamName: team.name, + }); + } + // Add the "team" for hosts with no team + teams.push({ + fleetApid: 0, + teamName: 'No team', + }); + + + let allScripts = []; + // Get all of the scripts on a Fleet instance. + for(let teamApid of teamApids){ + let scriptsResponseData = await sails.helpers.http.get.with({ + url: `/api/v1/fleet/scripts?team_id=${teamApid}`, + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let scriptsForThisTeam = scriptsResponseData.scripts; + if(scriptsForThisTeam !== null) { + allScripts = allScripts.concat(scriptsForThisTeam); + } + } + + // Grab all of the configuration scripts on the Fleet instance. + let noTeamConfigurationScriptsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/scripts', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let scriptsForThisTeam = noTeamConfigurationScriptsResponseData.scripts; + + if(scriptsForThisTeam !== null){ + allScripts = allScripts.concat(scriptsForThisTeam); + } + + // If there are no scripts on the Fleet instance, return an empty array and the teams information. + if(allScripts === [ null ]){ + return {scripts: [], teams}; + } + let scriptsOnThisFleetInstance = []; + + let allScriptsByIdentifier = _.groupBy(allScripts, 'name'); + for(let scriptIdentifier in allScriptsByIdentifier) { + if(scriptIdentifier === null){ + continue; + } + let teamsForThisProfile = []; + for(let script of allScriptsByIdentifier[scriptIdentifier]){ + let informationAboutThisScript = { + scriptFleetApid: script.id, + fleetApid: script.team_id ? script.team_id : 0, + teamName: script.team_id ? _.find(teams, {fleetApid: script.team_id}).teamName : 'No team', + }; + teamsForThisProfile.push(informationAboutThisScript); + } + let script = allScriptsByIdentifier[scriptIdentifier][0];// Grab the first script returned in the api repsonse to build our script configuration. + let scriptInformation = { + name: script.name, + identifier: scriptIdentifier, + platform: _.endsWith(script.name, 'sh') ? 'macOS & Linux' : 'Windows', + createdAt: new Date(script.created_at).getTime(), + teams: teamsForThisProfile + }; + scriptsOnThisFleetInstance.push(scriptInformation); + } + // Get the undeployed scripts from the app's database. + let undeployedScripts = await UndeployedScript.find(); + scriptsOnThisFleetInstance = _.union(scriptsOnThisFleetInstance, undeployedScripts); + // Sort scripts by their name. + scriptsOnThisFleetInstance = _.sortByOrder(scriptsOnThisFleetInstance, 'name', 'asc'); + return scriptsOnThisFleetInstance; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/legal/view-privacy.js b/ee/bulk-operations-dashboard/api/controllers/legal/view-privacy.js new file mode 100644 index 0000000000..6960be873f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/legal/view-privacy.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View privacy', + + + description: 'Display "Privacy policy" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/legal/privacy' + } + + }, + + + fn: async function () { + + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/legal/view-terms.js b/ee/bulk-operations-dashboard/api/controllers/legal/view-terms.js new file mode 100644 index 0000000000..643f04408a --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/legal/view-terms.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View terms', + + + description: 'Display "Legal terms" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/legal/terms' + } + + }, + + + fn: async function () { + + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/profiles/delete-profile.js b/ee/bulk-operations-dashboard/api/controllers/profiles/delete-profile.js new file mode 100644 index 0000000000..3a97405869 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/profiles/delete-profile.js @@ -0,0 +1,46 @@ +module.exports = { + + + friendlyName: 'Delete profile', + + + description: '', + + + inputs: { + profile: { + type: {}, + description: 'The configuration profile that will be deleted.', + required: true, + } + }, + + + exits: { + + }, + + + fn: async function ({profile}) { + // If the provided profile does not have a teams array and has an ID, it is an undeployed profile that will be deleted. + if(profile.id && !profile.teams){ + await UndeployedProfile.destroy({id: profile.id}); + } else {// Otherwise, this is a deployed profile, and we'll use information from the teams array to remove the profile. + for(let team of profile.teams){ + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles/${team.uuid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + } + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/profiles/download-profile.js b/ee/bulk-operations-dashboard/api/controllers/profiles/download-profile.js new file mode 100644 index 0000000000..ebbf569d52 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/profiles/download-profile.js @@ -0,0 +1,78 @@ +module.exports = { + + + friendlyName: 'Download profile', + + + description: 'Download profile file (returning a stream).', + + + inputs: { + id: { + type: 'number', + description: 'The database ID of the undeployed profile to download.' + }, + uuid: { + type: 'string', + description: 'The uuid of a profile on a team.' + }, + }, + + + exits: { + success: { + outputFriendlyName: 'File', + outputDescription: 'The streaming bytes of the file.', + outputType: 'ref' + }, + + notFound: { + description: 'No profile exists with the specified ID or UUID.', + responseType: 'notFound' + }, + }, + + + fn: async function ({id, uuid}) { + if(!uuid && !id){ + return this.res.badRequest(); + } + let datePrefix = new Date().toISOString(); + datePrefix = datePrefix.split('T')[0] +'_'; + let profileContents; + let filename; + let download; + if(id){ + let profileToDownload = await UndeployedProfile.findOne({id: id}); + + filename = datePrefix + profileToDownload.name + profileToDownload.profileType; + profileContents = profileToDownload.profileContents; + if(profileToDownload.profileType === '.mobileconfig'){ + this.res.type('application/x-apple-aspen-config'); + } else { + this.res.type('application/octet-stream'); + } + download = profileContents; + } else { + let profileDownloadResponse = await sails.helpers.http.sendHttpRequest.with({ + method: 'GET', + url: `${sails.config.custom.fleetBaseUrl}/api/v1/fleet/configuration_profiles/${uuid}?alt=media`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }); + let contentDispositionHeader = profileDownloadResponse.headers['content-disposition']; + let filenameMatch = contentDispositionHeader.replace(/^attachment;filename="(.+?)"$/, '$1'); + filename = filenameMatch; + let contentType = profileDownloadResponse.headers['content-type']; + download = profileDownloadResponse.body; + this.res.type(contentType); + } + this.res.attachment(filename); + // All done. + return download; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/profiles/edit-profile.js b/ee/bulk-operations-dashboard/api/controllers/profiles/edit-profile.js new file mode 100644 index 0000000000..bede01b44f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/profiles/edit-profile.js @@ -0,0 +1,204 @@ +module.exports = { + + + friendlyName: 'Edit profile', + + + description: 'Edits the teams a profile is assigned to and/or replaces the file on the Fleet instance if the new file\'s profile identifier matches', + + files: ['newProfile'], + + inputs: { + profile: { + type: {}, + description: 'The configuration profile that is being editted', + required: true, + }, + newTeamIds: { + type: ['string'], + description: 'An array of teams that this profile will be deployed on or Undefined if the profile is being removed from a team.' + }, + newProfile: { + type: 'ref', + description: 'A file that will be replacing the profile.' + }, + }, + + + exits: { + payloadIdentifierDoesNotMatch: { + statusCode: 409, + description: 'The new profiles bundle indentifer does not match the existing profile', + } + }, + + + + fn: async function ({profile, newTeamIds, newProfile}) { + if(newProfile.isNoop){ + newProfile.noMoreFiles(); + newProfile = undefined; + } + // ╔═╗╔═╗╔╦╗ ╔═╗╦═╗╔═╗╔═╗╦╦ ╔═╗ + // ║ ╦║╣ ║ ╠═╝╠╦╝║ ║╠╣ ║║ ║╣ + // ╚═╝╚═╝ ╩ ╩ ╩╚═╚═╝╚ ╩╩═╝╚═╝ + let profileContents; // The raw text contents of a profile file. + let filename; + let extension; + // If there is not a new profile, and the profile is deployed (has teams array === deployed), download the profile to be able to add it to other teams. + if(!newProfile && profile.teams){ + // console.log('Existing deployed profile!'); + let profileUuid = profile.teams[0].uuid; + let profileDownloadResponse = await sails.helpers.http.sendHttpRequest.with({ + method: 'GET', + url: `${sails.config.custom.fleetBaseUrl}/api/v1/fleet/configuration_profiles/${profileUuid}?alt=media`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }); + let contentDispositionHeader = profileDownloadResponse.headers['content-disposition']; + let filenameMatch = contentDispositionHeader.match(/filename="(.+?)"/); + filename = filenameMatch[1]; + extension = '.'+filename.split('.').pop(); + profileContents = profileDownloadResponse.body; + } else if(newProfile) {// Otherwise, if there is a new profile file uploaded, check that the payload identifier maches the existing profile on the Fleet instance. + // console.log('Replacing an existing(/undeployed) profile!'); + let file = await sails.reservoir(newProfile); + profileContents = file[0].contentBytes; + let profileFileName = file[0].name; + filename = profileFileName.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/\.[^/.]+$/, ''); + extension = '.'+profileFileName.split('.').pop(); + let profilePlatform = 'darwin'; + if(_.endsWith(profileFileName, '.xml')) { + profilePlatform = 'windows'; + } + if(newTeamIds && profile.teams && profilePlatform === 'darwin'){ + let existingProfileInfo = await sails.helpers.http.get.with({ + url: `${sails.config.custom.fleetBaseUrl}/api/v1/fleet/configuration_profiles/${profile.teams[0].uuid}?alt=media`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }); + let newProfileBundleIdentifier = profileContents.match(/PayloadIdentifier<\/key>\s*(.*?)<\/string>/)[1]; + let existingProfileBundleIdentifier = existingProfileInfo.match(/PayloadIdentifier<\/key>\s*(.*?)<\/string>/)[1]; + // Note: We're using the _.startsWith method to check that the identifier is the same. The identifiers returned by the Fleet instance are + if(existingProfileBundleIdentifier !== newProfileBundleIdentifier){ + throw 'payloadIdentifierDoesNotMatch'; + } + } + } else if (!newProfile && !profile.teams){// Undeployed profiles are stored in the app's database. + // console.log('editing an undeployed profile!'); + profileContents = profile.profileContents; + filename = profile.name; + extension = profile.profileType; + } + // ╔═╗╔═╗╔═╗╦╔═╗╔╗╔ ╔═╗╦═╗╔═╗╔═╗╦╦ ╔═╗ + // ╠═╣╚═╗╚═╗║║ ╦║║║ ╠═╝╠╦╝║ ║╠╣ ║║ ║╣ + // ╩ ╩╚═╝╚═╝╩╚═╝╝╚╝ ╩ ╩╚═╚═╝╚ ╩╩═╝╚═╝ + if(!newProfile){ + // If we're changing the teams for an existing profile, we'll remove this profile from any team not included in the newTeamIds array. + let currentProfileTeamIds = _.pluck(profile.teams, 'fleetApid'); + let addedTeams = _.difference(newTeamIds, currentProfileTeamIds); + let removedTeams = _.difference(currentProfileTeamIds, newTeamIds); + let removedTeamsInfo = _.filter(profile.teams, (team)=>{ + return removedTeams.includes(team.fleetApid); + }); + for(let team of removedTeamsInfo){ + // console.log(`removing ${profile.name} from team id ${team.teamName}`); + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles/${team.uuid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + for(let teamApid of addedTeams){ + // console.log(`Adding ${profile.name} to team id ${teamApid}`); + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`, + enctype: 'multipart/form-data', + body: { + team_id: teamApid,// eslint-disable-line camelcase + profile: { + options: { + filename: filename + extension, + contentType: 'application/octet-stream' + }, + value: profileContents, + } + }, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }); + }// After every added team + } else { + if(profile.teams) { + // If there is a new profile uploaded, we will need to delete the old profiles, and add the new profile. + for(let team of profile.teams) { + // console.log(`removing ${profile.name} from team id ${team.teamName}`); + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles/${team.uuid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + } + for(let teamApid of newTeamIds){ + // console.log(`Adding ${profile.name} to team id ${teamApid}`); + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`, + enctype: 'multipart/form-data', + body: { + team_id: teamApid,// eslint-disable-line camelcase + profile: { + options: { + filename: filename + extension, + contentType: 'application/octet-stream' + }, + value: profileContents, + } + }, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }); + }// After every added team + + } + // If this profile has an ID, then it is a database record, and we will delete it if it has been deployed to a team. + if(profile.id && newTeamIds.length > 0){ + // console.log('Undeployed profile has been deployed. deleting DB record!'); + await UndeployedProfile.destroy({id: profile.id}); + } else if(!profile.id && newTeamIds.length === 0){ + // If this is not a database record of a profile, and the profile is being undeployed from all teams, we'll create a databse record for it. + // console.log('Creating database record for a (now) undeployed profile!'); + await UndeployedProfile.create({ + name: profile.name, + platform: extension === '.xml' ? 'windows' : 'darwin', + profileContents, + profileType: extension, + }); + } else if(profile.id && newProfile){ + // If there is a new profile that is replacing a database record, update the profileContents in the database. + // console.log('Updating existing undeployed profile!'); + await UndeployedProfile.updateOne({id: profile.id}).set({ + profileContents, + }); + } + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/profiles/upload-profile.js b/ee/bulk-operations-dashboard/api/controllers/profiles/upload-profile.js new file mode 100644 index 0000000000..19c847bf62 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/profiles/upload-profile.js @@ -0,0 +1,120 @@ +module.exports = { + + + friendlyName: 'Upload profile', + + + description: '', + + files: ['newProfile'], + + inputs: { + newProfile: { + type: 'ref', + description: 'An Upstream with an incoming file upload.', + required: true, + }, + teams: { + type: ['string'], + description: 'An array of team IDs that this profile will be added to' + } + }, + + + exits: { + success: { + outputDescription: 'The new profile has been uploaded', + outputType: {}, + }, + + noFileAttached: { + description: 'No file was attached.', + responseType: 'badRequest' + }, + + tooBig: { + description: 'The file is too big.', + responseType: 'badRequest' + }, + + }, + + + fn: async function ({newProfile, teams}) { + let util = require('util'); + let profile = await sails.reservoir(newProfile) + .intercept('E_EXCEEDS_UPLOAD_LIMIT', 'tooBig') + .intercept((err)=>new Error('The configuration profile upload failed. '+util.inspect(err))); + if(!profile) { + throw 'noFileAttached'; + } + let profileContents = profile[0].contentBytes; + let profileFileName = profile[0].name; + let datelessExtensionlessFilename = profileFileName.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/\.[^/.]+$/, ''); + let extension = '.'+profileFileName.split('.').pop(); + let profilePlatform = 'darwin'; + if(_.endsWith(profileFileName, '.xml')) { + profilePlatform = 'windows'; + } + + let profileToReturn; + let newProfileInfo = { + name: datelessExtensionlessFilename, + platform: profilePlatform, + profileType: extension, + createdAt: Date.now(), + }; + if(!teams) { + newProfileInfo.profileContents = profileContents; + profileToReturn = await UndeployedProfile.create(newProfileInfo).fetch(); + } else { + let newTeams = []; + for(let teamApid of teams){ + let newProfileResponse = await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`, + enctype: 'multipart/form-data', + body: { + team_id: teamApid,// eslint-disable-line camelcase + profile: { + options: { + filename: profileFileName, + contentType: 'application/octet-stream' + }, + value: profileContents, + } + }, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }); + let parsedJsonResponse = JSON.parse(newProfileResponse.body); + let uuidForThisProfile = parsedJsonResponse.profile_uuid; + // send a request to the Fleet instance to get the bundleId of the new profile. + await sails.helpers.http.get.with({ + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/configuration_profiles/${uuidForThisProfile}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + newTeams.push({ + fleetApid: teamApid, + uuid: JSON.parse(newProfileResponse.body).profile_uuid + }); + } + newProfileInfo.teams = newTeams; + profileToReturn = newProfileInfo; + } + + + + + // All done. + return profileToReturn; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/profiles/view-profiles.js b/ee/bulk-operations-dashboard/api/controllers/profiles/view-profiles.js new file mode 100644 index 0000000000..9ebe807932 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/profiles/view-profiles.js @@ -0,0 +1,114 @@ +module.exports = { + + + friendlyName: 'View profiles', + + + description: 'Display "Profiles" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/profiles' + } + + }, + + + fn: async function () { + + // Get all teams on the Fleet instance. + let teamsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/teams', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + + let allTeams = teamsResponseData.teams; + + let teams = []; + for(let team of allTeams) { + teams.push({ + fleetApid: team.id, + teamName: team.name, + }); + } + // Add the "team" for hosts with no team + teams.push({ + fleetApid: 0, + teamName: 'No team', + }); + + + let allProfiles = []; + let teamApids = _.pluck(allTeams, 'id'); + // Get all of the configuration profiles on the Fleet instance. + for(let teamApid of teamApids){ + let configurationProfilesResponseData = await sails.helpers.http.get.with({ + url: `/api/v1/fleet/configuration_profiles?team_id=${teamApid}`, + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let profilesForThisTeam = configurationProfilesResponseData.profiles; + allProfiles = allProfiles.concat(profilesForThisTeam); + } + // Add the configurations profiles that are assigned to the "no team" team. + let noTeamConfigurationProfilesResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/configuration_profiles', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let profilesForThisTeam = noTeamConfigurationProfilesResponseData.profiles; + allProfiles = allProfiles.concat(profilesForThisTeam); + let profilesInformation = []; + // Group configuration profiles by their identifier. + let allProfilesByIdentifier = _.groupBy(allProfiles, 'identifier'); + for(let profileIdentifier in allProfilesByIdentifier) { + // Iterate through the arrays of profiles with the same unique identifier. + let teamsForThisProfile = []; + // Add the profile's UUID and information about the team this profile is assigned to the teams array for profiles. + for(let profile of allProfilesByIdentifier[profileIdentifier]) { + let informationAboutThisProfile = { + uuid: profile.profile_uuid, + fleetApid: profile.team_id, + teamName: _.find(teams, {fleetApid: profile.team_id}).teamName, + }; + teamsForThisProfile.push(informationAboutThisProfile); + } + let profile = allProfilesByIdentifier[profileIdentifier][0];// Grab the first profile returned in the api repsonse to build our profile configuration. + let profileInformation = { + name: profile.name, + identifier: profileIdentifier, + platform: profile.platform, + createdAt: new Date(profile.created_at).getTime(), + teams: teamsForThisProfile + }; + profilesInformation.push(profileInformation); + } + // Get the undeployed profiles from the app's database. + let undeployedProfiles = await UndeployedProfile.find(); + profilesInformation = _.union(profilesInformation, undeployedProfiles); + + // Sort profiles by their name. + profilesInformation = _.sortByOrder(profilesInformation, 'name', 'asc'); + + // Respond with view. + return {profiles: profilesInformation, teams}; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/scripts/delete-script.js b/ee/bulk-operations-dashboard/api/controllers/scripts/delete-script.js new file mode 100644 index 0000000000..f7c38c1664 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/scripts/delete-script.js @@ -0,0 +1,46 @@ +module.exports = { + + + friendlyName: 'Delete script', + + + description: '', + + + inputs: { + script: { + type: {}, + description: 'The script that will be deleted.', + required: true, + } + }, + + + exits: { + + }, + + + fn: async function ({script}) { + // If the provided script does not have a teams array and has an ID, it is an undeployed script that will be deleted. + if(script.id && !script.teams){ + await UndeployedScript.destroy({id: script.id}); + } else { + for(let teamScript of script.teams){ + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/scripts/${teamScript.scriptFleetApid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + } + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/scripts/download-script.js b/ee/bulk-operations-dashboard/api/controllers/scripts/download-script.js new file mode 100644 index 0000000000..4c9b7bc809 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/scripts/download-script.js @@ -0,0 +1,76 @@ +module.exports = { + + + friendlyName: 'Download script', + + + description: 'Download script file (returning a stream).', + + + inputs: { + fleetApid: { + type: 'number', + description: 'The Fleet API ID of the script to download.', + }, + id: { + type: 'number', + description: 'The database ID of the undeployed script to download' + } + }, + + + exits: { + success: { + outputFriendlyName: 'File', + outputDescription: 'The streaming bytes of the file.', + outputType: 'ref' + }, + + notFound: { + description: 'No script exists with the specified ID or UUID.', + responseType: 'notFound' + }, + }, + + + fn: async function ({fleetApid, id}) { + if(!fleetApid && !id){ + return this.res.badRequest(); + } + let filename; + let download; + if(id){ + let datePrefix = new Date().toISOString(); + datePrefix = datePrefix.split('T')[0] +'_'; + let scriptToDownload = await UndeployedScript.findOne({id: id}); + filename = datePrefix + scriptToDownload.name; + let scriptContents = scriptToDownload.scriptContents; + if(scriptToDownload.scriptType === '.sh'){ + this.res.type('application/x-apple-aspen-config'); + } else { + this.res.type('application/octet-stream'); + } + download = scriptContents; + } else { + let scriptDownloadResponse = await sails.helpers.http.sendHttpRequest.with({ + method: 'GET', + url: `${sails.config.custom.fleetBaseUrl}/api/v1/fleet/scripts/${fleetApid}?alt=media`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }); + let contentDispositionHeader = scriptDownloadResponse.headers['content-disposition']; + let filenameMatch = contentDispositionHeader.replace(/^attachment;filename="(.+?)"$/, '$1'); + filename = filenameMatch; + let contentType = scriptDownloadResponse.headers['content-type']; + download = scriptDownloadResponse.body; + this.res.type(contentType); + } + this.res.attachment(filename); + // All done. + return download; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/scripts/edit-script.js b/ee/bulk-operations-dashboard/api/controllers/scripts/edit-script.js new file mode 100644 index 0000000000..ec8d30f182 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/scripts/edit-script.js @@ -0,0 +1,197 @@ +module.exports = { + + + friendlyName: 'Edit script', + + + description: '', + + files: ['newScript'], + + inputs: { + script: { + type: {}, + description: 'The script that is being editted', + required: true, + }, + newTeamIds: { + type: ['ref'], + description: 'An array of teams that this script will be added to.' + }, + newScript: { + type: 'ref', + description: 'A file that will be replacing the script.' + }, + }, + + + exits: { + scriptNameDoesNotMatch: { + description: 'The provided replacement script\'s filename does not match the name of the script on the Fleet instance.', + statusCode: 400, + }, + }, + + + fn: async function ({script, newTeamIds, newScript}) { + if(newScript.isNoop){ + newScript.noMoreFiles(); + newScript = undefined; + } + let scriptContents; // The raw text contents of a script file. + let filename; + let extension; + // If there is not a new script, and the script is deployed (has teams array === deployed), download the script to be able to add it to other teams. + if(!newScript && script.teams){ + let scriptFleetApid = script.teams[0].scriptFleetApid; + let scriptDownloadResponse = await sails.helpers.http.sendHttpRequest.with({ + method: 'GET', + url: `${sails.config.custom.fleetBaseUrl}/api/v1/fleet/scripts/${scriptFleetApid}?alt=media`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }); + let contentDispositionHeader = scriptDownloadResponse.headers['content-disposition']; + let filenameMatch = contentDispositionHeader.match(/filename="(.+?)"/); + filename = filenameMatch[1]; + extension = '.'+filename.split('.').pop(); + filename = filename.replace(/^\d{4}-\d{2}-\d{2}[_|\s]?/, ''); + scriptContents = scriptDownloadResponse.body; + } else if(newScript) { + let file = await sails.reservoir(newScript); + scriptContents = file[0].contentBytes; + let scriptFilename = file[0].name; + filename = scriptFilename.replace(/^\d{4}-\d{2}-\d{2}[_|\s]?/, '').replace(/\.[^/.]+$/, ''); + extension = '.'+scriptFilename.split('.').pop(); + if(script.name !== filename+extension){ + throw 'scriptNameDoesNotMatch'; + } + } else if (!newScript && !script.teams){// Undeployed profiles are stored in the app's database. + // console.log('editing an undeployed profile!'); + scriptContents = script.scriptContents; + filename = script.name; + extension = script.scriptType; + } + + if(!newScript){ + let currentScriptTeamIds = _.pluck(script.teams, 'fleetApid'); + let addedTeams = _.difference(newTeamIds, currentScriptTeamIds); + let removedTeams = _.difference(currentScriptTeamIds, newTeamIds); + let removedTeamsInfo = _.filter(script.teams, (team)=>{ + return removedTeams.includes(team.fleetApid); + }); + for(let script of removedTeamsInfo){ + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/scripts/${script.scriptFleetApid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + for(let teamApid of addedTeams){ + // Build a request body for the team. + let requestBodyForThisTeam = { + script: { + options: { + filename: filename, + contentType: 'application/octet-stream' + }, + value: scriptContents, + } + }; + let addScriptUrl; + // If the script is being added to the "no team" team, then we need to include the team ID of the no team team in the request URL + if(Number(teamApid) === 0){ + addScriptUrl = `/api/v1/fleet/scripts?team_id=${teamApid}`; + } else { + // Otherwise, the team_id needs to be included in the request's formData. + addScriptUrl = `/api/v1/fleet/scripts`; + requestBodyForThisTeam.team_id = Number(teamApid);// eslint-disable-line camelcase + } + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url: addScriptUrl, + enctype: 'multipart/form-data', + body: requestBodyForThisTeam, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }); + } + } else { + if(script.teams) { + for(let scriptId of script.teams){ + await sails.helpers.http.sendHttpRequest.with({ + method: 'DELETE', + baseUrl: sails.config.custom.fleetBaseUrl, + url: `/api/v1/fleet/scripts/${scriptId.scriptFleetApid}`, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + } + }); + } + } + for(let teamApid of newTeamIds){ + // Build a request body for the team. + let requestBodyForThisTeam = { + script: { + options: { + filename: filename, + contentType: 'application/octet-stream' + }, + value: scriptContents, + } + }; + let addScriptUrl; + // If the script is being added to the "no team" team, then we need to include the team ID of the no team team in the request URL + if(Number(teamApid) === 0){ + addScriptUrl = `/api/v1/fleet/scripts?team_id=${teamApid}`; + } else { + // Otherwise, the team_id needs to be included in the request's formData. + addScriptUrl = `/api/v1/fleet/scripts`; + requestBodyForThisTeam.team_id = Number(teamApid);// eslint-disable-line camelcase + } + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url: addScriptUrl, + enctype: 'multipart/form-data', + body: requestBodyForThisTeam, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }); + } + } + + // If this profile has an ID, then it is a database record, and we will delete it if it has been deployed to a team. + if(script.id && newTeamIds.length > 0){ + // console.log('Undeployed script has been deployed. deleting DB record!'); + await UndeployedScript.destroy({id: script.id}); + } else if(!script.id && newTeamIds.length === 0){ + // If this is not a database record of a script, and the script is being undeployed from all teams, we'll create a databse record for it. + // console.log('Creating database record for a (now) undeployed script!'); + await UndeployedScript.create({ + name: script.name, + platform: extension === '.ps1' ? 'Windows' : 'macOS & Linux', + scriptContents, + scriptType: extension, + }); + } else if(script.id && newScript){ + // If there is a new script that is replacing a database record, update the scriptContents in the database. + // console.log('Updating existing undeployed script!'); + await UndeployedScript.updateOne({id: script.id}).set({ + scriptContents, + }); + } + + // All done. + return; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/scripts/upload-script.js b/ee/bulk-operations-dashboard/api/controllers/scripts/upload-script.js new file mode 100644 index 0000000000..55265f2fa2 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/scripts/upload-script.js @@ -0,0 +1,118 @@ +module.exports = { + + + friendlyName: 'Upload script', + + + description: 'Uploads a script to the connected Fleet instance', + + files: ['newScript'], + + inputs: { + + newScript: { + type: 'ref', + description: 'An Upstream with an incoming file upload.', + required: true, + }, + + teams: { + type: ['string'], + description: 'An array of team IDs that this profile will be added to' + } + }, + + + exits: { + success: { + outputDescription: 'The new script has been uploaded', + outputType: {}, + }, + + scriptWithThisNameAlreadyExists: { + description: 'A script with this name already exists on the Fleet Instance', + statusCode: 409, + }, + + noFileAttached: { + description: 'No file was attached.', + responseType: 'badRequest' + }, + + tooBig: { + description: 'The file is too big.', + responseType: 'badRequest' + }, + }, + + + fn: async function ({newScript, teams}) { + + let util = require('util'); + let script = await sails.reservoir(newScript) + .intercept('E_EXCEEDS_UPLOAD_LIMIT', 'tooBig') + .intercept((err)=>new Error('The script upload failed. '+util.inspect(err))); + if(!script) { + throw 'noFileAttached'; + } + // Get the file contents and filename. + let scriptContents = script[0].contentBytes; + let scriptFilename = script[0].name; + // Strip out any automatically added date prefixes from uploaded scripts. + let datelessExtensionlessFilename = scriptFilename.replace(/^\d{4}-\d{2}-\d{2}\s/, '').replace(/\.[^/.]+$/, ''); + let extension = '.'+scriptFilename.split('.').pop(); + // Build a dictonary of information about this script to return to the scripts page. + let newScriptInfo = { + name: datelessExtensionlessFilename, + platform: _.endsWith(scriptFilename, '.ps1') ? 'Windows' : 'macOS & Linux', + scriptType: extension, + createdAt: Date.now() + }; + if(!teams) { + newScriptInfo.scriptContents = scriptContents; + await UndeployedScript.create(newScriptInfo).fetch(); + } else { + // Send a request to add the script for every team ID in the array of teams. + for(let teamApid of teams){ + // Build a request body for the team. + let requestBodyForThisTeam = { + script: { + options: { + filename: datelessExtensionlessFilename + extension, + contentType: 'application/octet-stream' + }, + value: scriptContents, + } + }; + let addScriptUrl; + // If the script is being added to the "no team" team, then we need to include the team ID of the no team team in the request URL + if(Number(teamApid) === 0){ + addScriptUrl = `/api/v1/fleet/scripts?team_id=${teamApid}`; + } else { + // Otherwise, the team_id needs to be included in the request's formData. + addScriptUrl = `/api/v1/fleet/scripts`; + requestBodyForThisTeam.team_id = Number(teamApid);// eslint-disable-line camelcase + } + // Send a PSOT request to add the script. + await sails.helpers.http.sendHttpRequest.with({ + method: 'POST', + baseUrl: sails.config.custom.fleetBaseUrl, + url:addScriptUrl, + enctype: 'multipart/form-data', + body: requestBodyForThisTeam, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}`, + }, + }) + .intercept({raw: {statusCode: 409}}, ()=>{ + return 'scriptWithThisNameAlreadyExists'; + }); + } + } + // All done. + return newScriptInfo; + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/controllers/scripts/view-scripts.js b/ee/bulk-operations-dashboard/api/controllers/scripts/view-scripts.js new file mode 100644 index 0000000000..02fabebdd2 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/controllers/scripts/view-scripts.js @@ -0,0 +1,124 @@ +module.exports = { + + + friendlyName: 'View scripts', + + + description: 'Display "Scripts" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/scripts' + } + + }, + + + fn: async function () { + let teamsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/teams', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + + let allTeams = teamsResponseData.teams; + + let teamApids = _.pluck(allTeams, 'id'); + let teams = []; + for(let team of allTeams) { + teams.push({ + fleetApid: team.id, + teamName: team.name, + }); + } + // Add the "team" for hosts with no team + teams.push({ + fleetApid: 0, + teamName: 'No team', + }); + + let allScripts = []; + + for(let teamApid of teamApids){ + let scriptsResponseData = await sails.helpers.http.get.with({ + url: `/api/v1/fleet/scripts?team_id=${teamApid}`, + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let scriptsForThisTeam = scriptsResponseData.scripts; + if(scriptsForThisTeam !== null) { + allScripts = allScripts.concat(scriptsForThisTeam); + } + } + + // Grab all of the configuration scripts on the Fleet instance. + let noTeamConfigurationScriptsResponseData = await sails.helpers.http.get.with({ + url: '/api/v1/fleet/scripts', + baseUrl: sails.config.custom.fleetBaseUrl, + headers: { + Authorization: `Bearer ${sails.config.custom.fleetApiToken}` + } + }) + .timeout(120000) + .retry(['requestFailed', {name: 'TimeoutError'}]); + let scriptsForThisTeam = noTeamConfigurationScriptsResponseData.scripts; + + if(scriptsForThisTeam !== null){ + allScripts = allScripts.concat(scriptsForThisTeam); + } + + if(allScripts === [ null ]){ + return {scripts: [], teams}; + } + let scriptsOnThisFleetInstance = []; + + let allScriptsByIdentifier = _.groupBy(allScripts, 'name'); + for(let scriptIdentifier in allScriptsByIdentifier) { + if(scriptIdentifier === null){ + continue; + } + let teamsForThisProfile = []; + // console.log(teamsForThisProfile); + // let platforms = _.uniq(_.pluck(allScriptsByIdentifier[scriptIdentifier], 'platform')); + for(let script of allScriptsByIdentifier[scriptIdentifier]){ + let informationAboutThisScript = { + scriptFleetApid: script.id, + fleetApid: script.team_id ? script.team_id : 0, + teamName: script.team_id ? _.find(teams, {fleetApid: script.team_id}).teamName : 'No team', + }; + teamsForThisProfile.push(informationAboutThisScript); + } + let script = allScriptsByIdentifier[scriptIdentifier][0];// Grab the first script returned in the api repsonse to build our script configuration. + let scriptInformation = { + name: script.name, + identifier: scriptIdentifier, + platform: _.endsWith(script.name, 'sh') ? 'macOS & Linux' : 'Windows', + createdAt: new Date(script.created_at).getTime(), + teams: teamsForThisProfile + }; + scriptsOnThisFleetInstance.push(scriptInformation); + } + // Get the undeployed scripts from the app's database. + let undeployedScripts = await UndeployedScript.find(); + scriptsOnThisFleetInstance = _.union(scriptsOnThisFleetInstance, undeployedScripts); + + // Sort the scripts by name. + scriptsOnThisFleetInstance = _.sortByOrder(scriptsOnThisFleetInstance, 'name', 'asc'); + // Respond with view. + return {scripts: scriptsOnThisFleetInstance, teams}; + + + } + + +}; diff --git a/ee/bulk-operations-dashboard/api/helpers/broadcast-session-change.js b/ee/bulk-operations-dashboard/api/helpers/broadcast-session-change.js new file mode 100644 index 0000000000..f4aa836b3c --- /dev/null +++ b/ee/bulk-operations-dashboard/api/helpers/broadcast-session-change.js @@ -0,0 +1,45 @@ +module.exports = { + + + friendlyName: 'Broadcast session change', + + + description: 'Broadcast a socket notification indicating a change in login status.', + + + inputs: { + + req: { + type: 'ref', + required: true, + }, + + }, + + + exits: { + + success: { + description: 'All done.', + }, + + }, + + + fn: async function ({ req }) { + + // If there's no sessionID, we don't need to broadcase a message about the old session. + if(!req.sessionID) { + return; + } + + let roomName = `session${_.deburr(req.sessionID)}`; + let messageText = `You have signed out or signed into a different session in another tab or window. Reload the page to refresh your session.`; + sails.sockets.broadcast(roomName, 'session', { notificationText: messageText }, req); + + + } + + +}; + diff --git a/ee/bulk-operations-dashboard/api/helpers/redact-user.js b/ee/bulk-operations-dashboard/api/helpers/redact-user.js new file mode 100644 index 0000000000..28d9a7c44f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/helpers/redact-user.js @@ -0,0 +1,33 @@ +module.exports = { + + + friendlyName: 'Redact user', + + + description: 'Destructively remove properties from the provided User record to prepare it for publication.', + + + sync: true, + + + inputs: { + + user: { + type: 'ref', + readOnly: false + } + + }, + + + fn: function ({ user }) { + for (let [attrName, attrDef] of Object.entries(User.attributes)) { + if (attrDef.protect) { + delete user[attrName]; + }//fi + }//∞ + } + + +}; + diff --git a/ee/bulk-operations-dashboard/api/helpers/send-template-email.js b/ee/bulk-operations-dashboard/api/helpers/send-template-email.js new file mode 100644 index 0000000000..f03b447462 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/helpers/send-template-email.js @@ -0,0 +1,282 @@ +module.exports = { + + + friendlyName: 'Send template email', + + + description: 'Send an email using a template.', + + + extendedDescription: 'To ease testing and development, if the provided "to" email address ends in "@example.com", '+ + 'then the email message will be written to the terminal instead of actually being sent.'+ + '(Thanks [@simonratner](https://github.com/simonratner)!)', + + + inputs: { + + + template: { + description: 'The relative path to an EJS template within our `views/emails/` folder -- WITHOUT the file extension.', + extendedDescription: 'Use strings like "foo" or "foo/bar", but NEVER "foo/bar.ejs" or "/foo/bar". For example, '+ + '"internal/email-contact-form" would send an email using the "views/emails/internal/email-contact-form.ejs" template.', + example: 'email-reset-password', + type: 'string', + required: true + }, + + templateData: { + description: 'A dictionary of data which will be accessible in the EJS template.', + extendedDescription: 'Each key will be a local variable accessible in the template. For instance, if you supply '+ + 'a dictionary with a \`friends\` key, and \`friends\` is an array like \`[{name:"Chandra"}, {name:"Mary"}]\`),'+ + 'then you will be able to access \`friends\` from the template:\n'+ + '\`\`\`\n'+ + '
    \n'+ + '<% for (friend of friends){ %>
  • <%= friend.name %>
  • <% }); %>\n'+ + '
\n'+ + '\`\`\`'+ + '\n'+ + 'This is EJS, so use \`<%= %>\` to inject the HTML-escaped content of a variable, \`<%= %>\` to skip HTML-escaping '+ + 'and inject the data as-is, or \`<% %>\` to execute some JavaScript code such as an \`if\` statement or \`for\` loop.', + type: {}, + defaultsTo: {} + }, + + to: { + description: 'The email address of the primary recipient.', + extendedDescription: 'If this is any address ending in "@example.com", then don\'t actually deliver the message. '+ + 'Instead, just log it to the console.', + example: 'nola.thacker@example.com', + required: true, + isEmail: true, + }, + + toName: { + description: 'Name of the primary recipient as displayed in their inbox.', + example: 'Nola Thacker', + }, + + subject: { + description: 'The subject of the email.', + example: 'Hello there.', + defaultsTo: '' + }, + + from: { + description: 'An override for the default "from" email that\'s been configured.', + example: 'anne.martin@example.com', + isEmail: true, + }, + + fromName: { + description: 'An override for the default "from" name.', + example: 'Anne Martin', + }, + + layout: { + description: 'Set to `false` to disable layouts altogether, or provide the path (relative '+ + 'from `views/layouts/`) to an override email layout.', + defaultsTo: 'layout-email', + custom: (layout)=>layout===false || _.isString(layout) + }, + + ensureAck: { + description: 'Whether to wait for acknowledgement (to hear back) that the email was successfully sent (or at least queued for sending) before returning.', + extendedDescription: 'Otherwise by default, this returns immediately and delivers the request to deliver this email in the background.', + type: 'boolean', + defaultsTo: false + }, + + bcc: { + description: 'The email addresses of recipients secretly copied on the email.', + example: ['jahnna.n.malcolm@example.com'], + }, + + attachments: { + description: 'Attachments to include in the email, with the file content encoded as base64.', + whereToGet: { + description: 'If you have `sails-hook-uploads` installed, you can use `sails.reservoir` to get an attachment into the expected format.', + }, + example: [ + { + contentBytes: 'iVBORw0KGgoAA…', + name: 'sails.png', + type: 'image/png', + } + ], + defaultsTo: [], + }, + + }, + + + exits: { + + success: { + outputFriendlyName: 'Email delivery report', + outputDescription: 'A dictionary of information about what went down.', + outputType: { + loggedInsteadOfSending: 'boolean' + } + } + + }, + + + fn: async function({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments}) { + + var path = require('path'); + var url = require('url'); + var util = require('util'); + + + if (!_.startsWith(path.basename(template), 'email-')) { + sails.log.warn( + 'The "template" that was passed in to `sendTemplateEmail()` does not begin with '+ + '"email-" -- but by convention, all email template files in `views/emails/` should '+ + 'be namespaced in this way. (This makes it easier to look up email templates by '+ + 'filename; e.g. when using CMD/CTRL+P in Sublime Text.)\n'+ + 'Continuing regardless...' + ); + } + + if (_.startsWith(template, 'views/') || _.startsWith(template, 'emails/')) { + throw new Error( + 'The "template" that was passed in to `sendTemplateEmail()` was prefixed with\n'+ + '`emails/` or `views/` -- but that part is supposed to be omitted. Instead, please\n'+ + 'just specify the path to the desired email template relative from `views/emails/`.\n'+ + 'For example:\n'+ + ' template: \'email-reset-password\'\n'+ + 'Or:\n'+ + ' template: \'admin/email-contact-form\'\n'+ + ' [?] If you\'re unsure or need advice, see https://sailsjs.com/support' + ); + }//• + + // Determine appropriate email layout and template to use. + var emailTemplatePath = path.join('emails/', template); + var emailTemplateLayout; + if (layout) { + emailTemplateLayout = path.relative(path.dirname(emailTemplatePath), path.resolve('layouts/', layout)); + } else { + emailTemplateLayout = false; + } + + // Compile HTML template. + // > Note that we set the layout, provide access to core `url` package (for + // > building links and image srcs, etc.), and also provide access to core + // > `util` package (for dumping debug data in internal emails). + var htmlEmailContents = await sails.renderView( + emailTemplatePath, + _.extend({layout: emailTemplateLayout, url, util }, templateData) + ) + .intercept((err)=>{ + err.message = + 'Could not compile view template.\n'+ + '(Usually, this means the provided data is invalid, or missing a piece.)\n'+ + 'Details:\n'+ + err.message; + return err; + }); + + // Sometimes only log info to the console about the email that WOULD have been sent. + // Specifically, if the "To" email address is anything "@example.com". + // + // > This is used below when determining whether to actually send the email, + // > for convenience during development, but also for safety. (For example, + // > a special-cased version of "user@example.com" is used by Trend Micro Mars + // > scanner to "check apks for malware".) + var isToAddressConsideredFake = Boolean(to.match(/@example\.com$/i)); + + // If that's the case, or if we're in the "test" environment, then log + // the email instead of sending it: + var dontActuallySend = ( + sails.config.environment === 'test' || isToAddressConsideredFake + ); + if (dontActuallySend) { + sails.log( + 'Skipped sending email, either because the "To" email address ended in "@example.com"\n'+ + 'or because the current \`sails.config.environment\` is set to "test".\n'+ + '\n'+ + 'But anyway, here is what WOULD have been sent:\n'+ + '-=-=-=-=-=-=-=-=-=-=-=-=-= Email log =-=-=-=-=-=-=-=-=-=-=-=-=-\n'+ + 'To: '+to+'\n'+ + 'Subject: '+subject+'\n'+ + '\n'+ + 'Body:\n'+ + htmlEmailContents+'\n'+ + '-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-' + ); + } else { + // Otherwise, we'll check that all required Mailgun credentials are set up + // and, if so, continue to actually send the email. + + if (!sails.config.custom.sendgridSecret) { + throw new Error( + 'Cannot deliver email to "'+to+'" because:\n'+ + (()=>{ + let problems = []; + if (!sails.config.custom.sendgridSecret) { + problems.push(' • Sendgrid secret is missing from this app\'s configuration (`sails.config.custom.sendgridSecret`)'); + } + return problems.join('\n'); + })()+ + '\n'+ + 'To resolve these configuration issues, add the missing config variables to\n'+ + '\`config/custom.js\`-- or in staging/production, set them up as system\n'+ + 'environment vars. (If you don\'t have a Sendgrid secret, you can\n'+ + 'sign up for free at https://sendgrid.com to receive credentials.)\n'+ + '\n'+ + '> Note that, for convenience during development, there is another alternative:\n'+ + '> In lieu of setting up real Sendgrid credentials, you can "fake" email\n'+ + '> delivery by using any email address that ends in "@example.com". This will\n'+ + '> write automated emails to your logs rather than actually sending them.\n'+ + '> (To simulate clicking on a link from an email, just copy and paste the link\n'+ + '> from the terminal output into your browser.)\n'+ + '\n'+ + '[?] If you\'re unsure, visit https://sailsjs.com/support' + ); + } + + var subjectLinePrefix = sails.config.environment === 'production' ? '' : sails.config.environment === 'staging' ? '[FROM STAGING] ' : '[FROM LOCALHOST] '; + var messageData = { + htmlMessage: htmlEmailContents, + to: to, + toName: toName, + bcc: bcc, + subject: subjectLinePrefix+subject, + from: from, + fromName: fromName, + attachments + }; + + var deferred = sails.helpers.sendgrid.sendHtmlEmail.with(messageData); + if (ensureAck) { + await deferred; + } else { + // FUTURE: take advantage of .background() here instead (when available) + deferred.exec((err)=>{ + if (err) { + sails.log.error( + 'Background instruction failed: Could not deliver email:\n'+ + util.inspect({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments},{depth:null})+'\n', + 'Error details:\n'+ + util.inspect(err) + ); + } else { + sails.log.info( + 'Background instruction complete: Email sent via email delivery service (or at least queued):\n'+ + util.inspect({to, toName, subject, from, fromName, bcc},{depth:null}) + ); + } + });//_∏_ + }//fi + }//fi + + // All done! + return { + loggedInsteadOfSending: dontActuallySend, + }; + + } + +}; diff --git a/ee/bulk-operations-dashboard/api/hooks/custom/index.js b/ee/bulk-operations-dashboard/api/hooks/custom/index.js new file mode 100644 index 0000000000..be391d7097 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/hooks/custom/index.js @@ -0,0 +1,275 @@ +/** + * @description :: The conventional "custom" hook. Extends this app with custom server-start-time and request-time logic. + * @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks + */ + +module.exports = function defineCustomHook(sails) { + + return { + + /** + * Runs when a Sails app loads/lifts. + */ + initialize: async function () { + + sails.log.info('Initializing project hook... (`api/hooks/custom/`)'); + + // Check Stripe/Sendgrid configuration (for billing and emails). + var IMPORTANT_STRIPE_CONFIG = ['stripeSecret', 'stripePublishableKey']; + var IMPORTANT_SENDGRID_CONFIG = ['sendgridSecret', 'internalEmailAddress']; + var isMissingStripeConfig = _.difference(IMPORTANT_STRIPE_CONFIG, Object.keys(sails.config.custom)).length > 0; + var isMissingSendgridConfig = _.difference(IMPORTANT_SENDGRID_CONFIG, Object.keys(sails.config.custom)).length > 0; + + if (isMissingStripeConfig || isMissingSendgridConfig) { + + let missingFeatureText = isMissingStripeConfig && isMissingSendgridConfig ? 'billing and email' : isMissingStripeConfig ? 'billing' : 'email'; + let suffix = ''; + if (_.contains(['silly'], sails.config.log.level)) { + suffix = +` +> Tip: To exclude sensitive credentials from source control, use: +> • config/local.js (for local development) +> • environment variables (for production) +> +> If you want to check them in to source control, use: +> • config/custom.js (for development) +> • config/env/staging.js (for staging) +> • config/env/production.js (for production) +> +> (See https://sailsjs.com/docs/concepts/configuration for help configuring Sails.) +`; + } + + let problems = []; + if (sails.config.custom.stripeSecret === undefined) { + problems.push('No `sails.config.custom.stripeSecret` was configured.'); + } + if (sails.config.custom.stripePublishableKey === undefined) { + problems.push('No `sails.config.custom.stripePublishableKey` was configured.'); + } + if (sails.config.custom.sendgridSecret === undefined) { + problems.push('No `sails.config.custom.sendgridSecret` was configured.'); + } + if (sails.config.custom.internalEmailAddress === undefined) { + problems.push('No `sails.config.custom.internalEmailAddress` was configured.'); + } + + + if (sails.config.custom.fleetBaseUrl === undefined) { + throw new Error('Missing config vairable! Please set sails.config.custom.fleetBaseUrl to be the URL of your Fleet instance.'); + } + if (sails.config.custom.fleetApiToken === undefined) { + throw new Error('Missing config vairable! Please set sails.config.custom.fleetApiToken to be a token for your Fleet instance.'); + } + + + if (_.endsWith(sails.config.custom.fleetBaseUrl, '/')) { + sails.config.custom.fleetBaseUrl = _.trimRight(sails.config.custom.fleetBaseUrl, '/'); + sails.log.warn('Warning: The provided sails.config.custom.fleetBaseUrl has a trailing slash. To make sure all auto-generated URLs work as expected, this trailing slash has been removed for you.'); + } + if (!_.startsWith(sails.config.custom.fleetBaseUrl, 'https://')) { + sails.log.warn('Warning: The provided sails.config.custom.fleetBaseUrl is missing a protocol (https://). To make sure all auto-generated URLs work as expected, the protocol has been added to the fleetBaseUrl.'); + sails.config.custom.fleetBaseUrl = 'https://'+sails.config.custom.fleetBaseUrl; + } + + sails.log.verbose( +`Some optional settings have not been configured yet: +--------------------------------------------------------------------- +${problems.join('\n')} + +Until this is addressed, this app's ${missingFeatureText} features +will be disabled and/or hidden in the UI. + + [?] If you're unsure or need advice, come by https://sailsjs.com/support +---------------------------------------------------------------------${suffix}`); + }//fi + + // Set an additional config keys based on whether Stripe config is available. + // This will determine whether or not to enable various billing features. + sails.config.custom.enableBillingFeatures = !isMissingStripeConfig; + + // After "sails-hook-organics" finishes initializing, configure Stripe + // and Sendgrid packs with any available credentials. + sails.after('hook:organics:loaded', ()=>{ + + sails.helpers.stripe.configure({ + secret: sails.config.custom.stripeSecret + }); + + sails.helpers.sendgrid.configure({ + secret: sails.config.custom.sendgridSecret, + from: sails.config.custom.fromEmailAddress, + fromName: sails.config.custom.fromName, + }); + + });//_∏_ + + // ... Any other app-specific setup code that needs to run on lift, + // even in production, goes here ... + + }, + + + routes: { + + /** + * Runs before every matching route. + * + * @param {Ref} req + * @param {Ref} res + * @param {Function} next + */ + before: { + '/*': { + skipAssets: true, + fn: async function(req, res, next){ + + var url = require('url'); + + // First, if this is a GET request (and thus potentially a view), + // attach a couple of guaranteed locals. + if (req.method === 'GET') { + + // The `_environment` local lets us do a little workaround to make Vue.js + // run in "production mode" without unnecessarily involving complexities + // with webpack et al.) + if (res.locals._environment !== undefined) { + throw new Error('Cannot attach Sails environment as the view local `_environment`, because this view local already exists! (Is it being attached somewhere else?)'); + } + res.locals._environment = sails.config.environment; + + // The `me` local is set explicitly to `undefined` here just to avoid having to + // do `typeof me !== 'undefined'` checks in our views/layouts/partials. + // > Note that, depending on the request, this may or may not be set to the + // > logged-in user record further below. + if (res.locals.me !== undefined) { + throw new Error('Cannot attach view local `me`, because this view local already exists! (Is it being attached somewhere else?)'); + } + res.locals.me = 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 + // automatically go ahead and redirect to the corresponding path under + // our base URL, which is environment-specific. + // > Note that we DO NOT redirect virtual socket requests and we DO NOT + // > redirect non-GET requests (because it can confuse some 3rd party + // > platforms that send webhook requests.) We also DO NOT redirect + // > requests in other environments to allow for flexibility during + // > development (e.g. so you can preview an app running locally on + // > your laptop using a local IP address or a tool like ngrok, in + // > case you want to run it on a real, physical mobile/IoT device) + var configuredBaseHostname; + try { + configuredBaseHostname = url.parse(sails.config.custom.baseUrl).host; + } catch (unusedErr) { /*…*/} + if ((sails.config.environment === 'staging' || sails.config.environment === 'production') && !req.isSocket && req.method === 'GET' && req.hostname !== configuredBaseHostname) { + sails.log.info('Redirecting GET request from `'+req.hostname+'` to configured expected host (`'+configuredBaseHostname+'`)...'); + return res.redirect(sails.config.custom.baseUrl+req.url); + }//• + + // Prevent the browser from caching logged-in users' pages. + // (including w/ the Chrome back button) + // > • https://mixmax.com/blog/chrome-back-button-cache-no-store + // > • https://madhatted.com/2013/6/16/you-do-not-understand-browser-history + // + // This also prevents an issue where webpages may be cached by browsers, and thus + // reference an old bundle file (e.g. dist/production.min.js or dist/production.min.css), + // which might have a different hash encoded in its filename. This way, by preventing caching + // of the webpage itself, the HTML is always fresh, and thus always trying to load the latest, + // correct bundle files. + res.setHeader('Cache-Control', 'no-cache, no-store'); + + // No session? Proceed as usual. + // (e.g. request for a static asset) + if (!req.session) { return next(); } + + // Not logged in? Proceed as usual. + if (!req.session.userId) { return next(); } + + // Otherwise, look up the logged-in user. + var loggedInUser = await User.findOne({ + id: req.session.userId + }); + + // If the logged-in user has gone missing, log a warning, + // wipe the user id from the requesting user agent's session, + // and then send the "unauthorized" response. + if (!loggedInUser) { + sails.log.warn('Somehow, the user record for the logged-in user (`'+req.session.userId+'`) has gone missing....'); + delete req.session.userId; + return res.unauthorized(); + } + + // Add additional information for convenience when building top-level navigation. + // (i.e. whether to display "Dashboard", "My Account", etc.) + if (!loggedInUser.password || loggedInUser.emailStatus === 'unconfirmed') { + loggedInUser.dontDisplayAccountLinkInNav = true; + } + + // Expose the user record as an extra property on the request object (`req.me`). + // > Note that we make sure `req.me` doesn't already exist first. + if (req.me !== undefined) { + throw new Error('Cannot attach logged-in user as `req.me` because this property already exists! (Is it being attached somewhere else?)'); + } + req.me = loggedInUser; + + // If our "lastSeenAt" attribute for this user is at least a few seconds old, then set it + // to the current timestamp. + // + // (Note: As an optimization, this is run behind the scenes to avoid adding needless latency.) + var MS_TO_BUFFER = 60*1000; + var now = Date.now(); + if (loggedInUser.lastSeenAt < now - MS_TO_BUFFER) { + User.updateOne({id: loggedInUser.id}) + .set({ lastSeenAt: now }) + .exec((err)=>{ + if (err) { + sails.log.error('Background task failed: Could not update user (`'+loggedInUser.id+'`) with a new `lastSeenAt` timestamp. Error details: '+err.stack); + return; + }//• + sails.log.verbose('Updated the `lastSeenAt` timestamp for user `'+loggedInUser.id+'`.'); + // Nothing else to do here. + });//_∏_ (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. + if (req.method === 'GET') { + if (res.locals.me !== undefined) { + throw new Error('Cannot attach logged-in user as the view local `me`, because this view local already exists! (Is it being attached somewhere else?)'); + } + + // Exclude any fields corresponding with attributes that have `protect: true`. + var sanitizedUser = _.extend({}, loggedInUser); + sails.helpers.redactUser(sanitizedUser); + + // If there is still a "password" in sanitized user data, then delete it just to be safe. + // (But also log a warning so this isn't hopelessly confusing.) + if (sanitizedUser.password) { + sails.log.warn('The logged in user record has a `password` property, but it was still there after pruning off all properties that match `protect: true` attributes in the User model. So, just to be safe, removing the `password` property anyway...'); + delete sanitizedUser.password; + }//fi + + res.locals.me = sanitizedUser; + + // Include information on the locals as to whether billing features + // are enabled for this app, and whether email verification is required. + res.locals.isBillingEnabled = sails.config.custom.enableBillingFeatures; + res.locals.isEmailVerificationRequired = sails.config.custom.verifyEmailAddresses; + + }//fi + + return next(); + } + } + } + } + + + }; + +}; diff --git a/ee/bulk-operations-dashboard/api/models/UndeployedProfile.js b/ee/bulk-operations-dashboard/api/models/UndeployedProfile.js new file mode 100644 index 0000000000..2d13a1993f --- /dev/null +++ b/ee/bulk-operations-dashboard/api/models/UndeployedProfile.js @@ -0,0 +1,61 @@ +/** + * UndeployedProfile.js + * + * @description :: A model definition represents a database table/collection. + * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models + */ + +module.exports = { + + attributes: { + + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + name: { + type: 'string', + required: true, + description: 'The name of the configuration profile on the Fleet instance.', + }, + + platform: { + type: 'string', + description: 'The type of operating system this profile is for.', + required: true, + isIn: [ + 'darwin', + 'windows' + ] + }, + + profileType: { + type: 'string', + description: 'The file extension of the configuration profile.', + required: true, + isIn: [ + '.mobileconfig', + '.xml', + '.json', + ], + }, + + profileContents: { + type: 'string', + required: true, + description: 'The contents of the configuration profile.', + }, + + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + + }, + +}; + diff --git a/ee/bulk-operations-dashboard/api/models/UndeployedScript.js b/ee/bulk-operations-dashboard/api/models/UndeployedScript.js new file mode 100644 index 0000000000..2ce07c2158 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/models/UndeployedScript.js @@ -0,0 +1,60 @@ +/** + * UndeployedScript.js + * + * @description :: A model definition represents a database table/collection. + * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models + */ + +module.exports = { + + attributes: { + + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + name: { + type: 'string', + required: true, + description: 'The name of the script on the Fleet instance.', + }, + + platform: { + type: 'string', + description: 'The type of operating system this script is for.', + required: true, + isIn: [ + 'macOS & Linux', + 'Windows' + ], + }, + + scriptType: { + type: 'string', + description: 'The file extension of the script.', + required: true, + isIn: [ + '.sh', + '.ps1', + ], + }, + + scriptContents: { + type: 'string', + required: true, + description: 'The contents of the script stored as a string.', + }, + + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + + }, + +}; + diff --git a/ee/bulk-operations-dashboard/api/models/User.js b/ee/bulk-operations-dashboard/api/models/User.js new file mode 100644 index 0000000000..983627db7a --- /dev/null +++ b/ee/bulk-operations-dashboard/api/models/User.js @@ -0,0 +1,171 @@ +/** + * User.js + * + * A user who can log in to this application. + */ + +module.exports = { + + attributes: { + + // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗ + // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗ + // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝ + + emailAddress: { + type: 'string', + required: true, + unique: true, + isEmail: true, + maxLength: 200, + example: 'mary.sue@example.com' + }, + + emailStatus: { + type: 'string', + isIn: ['unconfirmed', 'change-requested', 'confirmed'], + defaultsTo: 'confirmed', + description: 'The confirmation status of the user\'s email address.', + extendedDescription: +`Users might be created as "unconfirmed" (e.g. normal signup) or as "confirmed" (e.g. hard-coded +admin users). When the email verification feature is enabled, new users created via the +signup form have \`emailStatus: 'unconfirmed'\` until they click the link in the confirmation email. +Similarly, when an existing user changes their email address, they switch to the "change-requested" +email status until they click the link in the confirmation email.` + }, + + emailChangeCandidate: { + type: 'string', + isEmail: true, + description: 'A still-unconfirmed email address that this user wants to change to (if relevant).' + }, + + password: { + type: 'string', + required: true, + description: 'Securely hashed representation of the user\'s login password.', + protect: true, + example: '2$28a8eabna301089103-13948134nad' + }, + + fullName: { + type: 'string', + required: true, + description: 'Full representation of the user\'s name.', + maxLength: 120, + example: 'Mary Sue van der McHenst' + }, + + isSuperAdmin: { + type: 'boolean', + description: 'Whether this user is a "super admin" with extra permissions, etc.', + extendedDescription: +`Super admins might have extra permissions, see a different default home page when they log in, +or even have a completely different feature set from normal users. In this app, the \`isSuperAdmin\` +flag is just here as a simple way to represent two different kinds of users. Usually, it's a good idea +to keep the data model as simple as possible, only adding attributes when you actually need them for +features being built right now. + +For example, a "super admin" user for a small to medium-sized e-commerce website might be able to +change prices, deactivate seasonal categories, add new offerings, and view live orders as they come in. +On the other hand, for an e-commerce website like Walmart.com that has undergone years of development +by a large team, those administrative features might be split across a few different roles. + +So, while this \`isSuperAdmin\` demarcation might not be the right approach forever, it's a good place to start.` + }, + + passwordResetToken: { + type: 'string', + description: 'A unique token used to verify the user\'s identity when recovering a password. Expires after 1 use, or after a set amount of time has elapsed.' + }, + + passwordResetTokenExpiresAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).', + example: 1502844074211 + }, + + emailProofToken: { + type: 'string', + description: 'A pseudorandom, probabilistically-unique token for use in our account verification emails.' + }, + + emailProofTokenExpiresAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `emailProofToken` will expire (or 0 if the user currently has no such token).', + example: 1502844074211 + }, + + stripeCustomerId: { + type: 'string', + protect: true, + description: 'The id of the customer entry in Stripe associated with this user (or empty string if this user is not linked to a Stripe customer -- e.g. if billing features are not enabled).', + extendedDescription: +`Just because this value is set doesn't necessarily mean that this user has a billing card. +It just means they have a customer entry in Stripe, which might or might not have a billing card.` + }, + + hasBillingCard: { + type: 'boolean', + description: 'Whether this user has a default billing card hooked up as their payment method.', + extendedDescription: +`More specifically, this indcates whether this user record's linked customer entry in Stripe has +a default payment source (i.e. credit card). Note that a user have a \`stripeCustomerId\` +without necessarily having a billing card.` + }, + + billingCardBrand: { + type: 'string', + example: 'Visa', + description: 'The brand of this user\'s default billing card (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardLast4: { + type: 'string', + example: '4242', + description: 'The last four digits of the card number for this user\'s default billing card (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardExpMonth: { + type: 'string', + example: '08', + description: 'The two-digit expiration month from this user\'s default billing card, formatted as MM (or empty string if no billing card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + billingCardExpYear: { + type: 'string', + example: '2023', + description: 'The four-digit expiration year from this user\'s default billing card, formatted as YYYY (or empty string if no credit card is set up).', + extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.' + }, + + tosAcceptedByIp: { + type: 'string', + description: 'The IP (ipv4) address of the request that accepted the terms of service.', + extendedDescription: 'Useful for certain types of businesses and regulatory requirements (KYC, etc.)', + moreInfoUrl: 'https://en.wikipedia.org/wiki/Know_your_customer' + }, + + lastSeenAt: { + type: 'number', + description: 'A JS timestamp (epoch ms) representing the moment at which this user most recently interacted with the backend while logged in (or 0 if they have not interacted with the backend at all yet).', + example: 1502844074211 + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + // n/a + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + // n/a + + }, + + +}; diff --git a/ee/bulk-operations-dashboard/api/policies/is-logged-in.js b/ee/bulk-operations-dashboard/api/policies/is-logged-in.js new file mode 100644 index 0000000000..0c03b1a689 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/policies/is-logged-in.js @@ -0,0 +1,26 @@ +/** + * is-logged-in + * + * A simple policy that allows any request from an authenticated user. + * + * For more about how to use policies, see: + * https://sailsjs.com/config/policies + * https://sailsjs.com/docs/concepts/policies + * https://sailsjs.com/docs/concepts/policies/access-control-and-permissions + */ +module.exports = async function (req, res, proceed) { + + // If `req.me` is set, then we know that this request originated + // from a logged-in user. So we can safely proceed to the next policy-- + // or, if this is the last policy, the relevant action. + // > For more about where `req.me` comes from, check out this app's + // > custom hook (`api/hooks/custom/index.js`). + if (req.me) { + return proceed(); + } + + //--• + // Otherwise, this request did not come from a logged-in user. + return res.unauthorized(); + +}; diff --git a/ee/bulk-operations-dashboard/api/policies/is-super-admin.js b/ee/bulk-operations-dashboard/api/policies/is-super-admin.js new file mode 100644 index 0000000000..09c473aa9d --- /dev/null +++ b/ee/bulk-operations-dashboard/api/policies/is-super-admin.js @@ -0,0 +1,28 @@ +/** + * is-super-admin + * + * A simple policy that blocks requests from non-super-admins. + * + * For more about how to use policies, see: + * https://sailsjs.com/config/policies + * https://sailsjs.com/docs/concepts/policies + * https://sailsjs.com/docs/concepts/policies/access-control-and-permissions + */ +module.exports = async function (req, res, proceed) { + + // First, check whether the request comes from a logged-in user. + // > For more about where `req.me` comes from, check out this app's + // > custom hook (`api/hooks/custom/index.js`). + if (!req.me) { + return res.unauthorized(); + }//• + + // Then check that this user is a "super admin". + if (!req.me.isSuperAdmin) { + return res.forbidden(); + }//• + + // IWMIH, we've got ourselves a "super admin". + return proceed(); + +}; diff --git a/ee/bulk-operations-dashboard/api/responses/expired.js b/ee/bulk-operations-dashboard/api/responses/expired.js new file mode 100644 index 0000000000..c71239adbf --- /dev/null +++ b/ee/bulk-operations-dashboard/api/responses/expired.js @@ -0,0 +1,37 @@ +/** + * expired.js + * + * A custom response that content-negotiates the current request to either: + * • serve an HTML error page about the specified token being invalid or expired + * • or send back 498 (Token Expired/Invalid) with no response body. + * + * Example usage: + * ``` + * return res.expired(); + * ``` + * + * Or with actions2: + * ``` + * exits: { + * badToken: { + * description: 'Provided token was expired, invalid, or already used up.', + * responseType: 'expired' + * } + * } + * ``` + */ +module.exports = function expired() { + + var req = this.req; + var res = this.res; + + sails.log.verbose('Ran custom response: res.expired()'); + + if (req.wantsJSON) { + return res.status(498).send('Token Expired/Invalid'); + } + else { + return res.status(498).view('498'); + } + +}; diff --git a/ee/bulk-operations-dashboard/api/responses/unauthorized.js b/ee/bulk-operations-dashboard/api/responses/unauthorized.js new file mode 100644 index 0000000000..650cb992f2 --- /dev/null +++ b/ee/bulk-operations-dashboard/api/responses/unauthorized.js @@ -0,0 +1,43 @@ +/** + * unauthorized.js + * + * A custom response that content-negotiates the current request to either: + * • log out the current user and redirect them to the login page + * • or send back 401 (Unauthorized) with no response body. + * + * Example usage: + * ``` + * return res.unauthorized(); + * ``` + * + * Or with actions2: + * ``` + * exits: { + * badCombo: { + * description: 'That email address and password combination is not recognized.', + * responseType: 'unauthorized' + * } + * } + * ``` + */ +module.exports = function unauthorized() { + + var req = this.req; + var res = this.res; + + sails.log.verbose('Ran custom response: res.unauthorized()'); + + if (req.wantsJSON) { + return res.sendStatus(401); + } + // Or log them out (if necessary) and then redirect to the login page. + else { + + if (req.session.userId) { + delete req.session.userId; + } + + return res.redirect('/login'); + } + +}; diff --git a/ee/bulk-operations-dashboard/app.js b/ee/bulk-operations-dashboard/app.js new file mode 100644 index 0000000000..f2c5f4eba8 --- /dev/null +++ b/ee/bulk-operations-dashboard/app.js @@ -0,0 +1,54 @@ +/** + * app.js + * + * Use `app.js` to run your app without `sails lift`. + * To start the server, run: `node app.js`. + * + * This is handy in situations where the sails CLI is not relevant or useful, + * such as when you deploy to a server, or a PaaS like Heroku. + * + * For example: + * => `node app.js` + * => `npm start` + * => `forever start app.js` + * => `node debug app.js` + * + * The same command-line arguments and env vars are supported, e.g.: + * `NODE_ENV=production node app.js --port=80 --verbose` + * + * For more information see: + * https://sailsjs.com/anatomy/app.js + */ + + +// Ensure we're in the project directory, so cwd-relative paths work as expected +// no matter where we actually lift from. +// > Note: This is not required in order to lift, but it is a convenient default. +process.chdir(__dirname); + + + +// Attempt to import `sails` dependency, as well as `rc` (for loading `.sailsrc` files). +var sails; +var rc; +try { + sails = require('sails'); + rc = require('sails/accessible/rc'); +} catch (err) { + console.error('Encountered an error when attempting to require(\'sails\'):'); + console.error(err.stack); + console.error('--'); + console.error('To run an app using `node app.js`, you need to have Sails installed'); + console.error('locally (`./node_modules/sails`). To do that, just make sure you\'re'); + console.error('in the same directory as your app and run `npm install`.'); + console.error(); + console.error('If Sails is installed globally (i.e. `npm install -g sails`) you can'); + console.error('also run this app with `sails lift`. Running with `sails lift` will'); + console.error('not run this file (`app.js`), but it will do exactly the same thing.'); + console.error('(It even uses your app directory\'s local Sails install, if possible.)'); + return; +}//-• + + +// Start server +sails.lift(rc('sails')); diff --git a/ee/bulk-operations-dashboard/assets/.eslintrc b/ee/bulk-operations-dashboard/assets/.eslintrc new file mode 100644 index 0000000000..28cf2eb151 --- /dev/null +++ b/ee/bulk-operations-dashboard/assets/.eslintrc @@ -0,0 +1,63 @@ +{ + // ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐ + // ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤ + // o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘ + // ┌─ ┌─┐┌─┐┬─┐ ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ ┬┌─┐ ┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐ ─┐ + // │ ├┤ │ │├┬┘ ├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ │└─┐ ├─┤└─┐└─┐├┤ │ └─┐ │ + // └─ └ └─┘┴└─ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ └┘└─┘ ┴ ┴└─┘└─┘└─┘ ┴ └─┘ ─┘ + // > An .eslintrc configuration override for use in the `assets/` directory. + // + // This extends the top-level .eslintrc file, primarily to change the set of + // supported globals, as well as any other relevant settings. (Since JavaScript + // code in the `assets/` folder is intended for the browser habitat, a different + // set of globals is supported. For example, instead of Node.js/Sails globals + // like `sails` and `process`, you have access to browser globals like `window`.) + // + // (See .eslintrc in the root directory of this Sails app for more context.) + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + "extends": [ + "../.eslintrc" + ], + + "env": { + "browser": true, + "node": false + }, + + "parserOptions": { + "ecmaVersion": 8 + //^ If you are not using a transpiler like Babel, change this to `5`. + }, + + "globals": { + + // Allow any window globals you're relying on here; e.g. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "SAILS_LOCALS": true, + "io": true, + "Cloud": true, + "parasails": true, + "$": true, + "_": true, + "bowser": true, + "StripeCheckout": true, + "Stripe": true, + "Vue": true, + "VueRouter": true, + "moment": true, + // "google": true, + // ...etc. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + // Make sure backend globals aren't indadvertently tolerated in our client-side JS: + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + "sails": false, + "User": false, + "UndeployedProfile": false, + "UndeployedScript": false + // ...and any other backend globals (e.g. `"Organization": false`) + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + } + +} diff --git a/ee/bulk-operations-dashboard/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js b/ee/bulk-operations-dashboard/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js new file mode 100644 index 0000000000..e8b832da6b --- /dev/null +++ b/ee/bulk-operations-dashboard/assets/dependencies/bootstrap-4/bootstrap-4.bundle.js @@ -0,0 +1,6461 @@ +/*! + * Bootstrap v4.1.3 (https://getbootstrap.com/) + * Copyright 2011-2018 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery')) : + typeof define === 'function' && define.amd ? define(['exports', 'jquery'], factory) : + (factory((global.bootstrap = {}),global.jQuery)); +}(this, (function (exports,$) { 'use strict'; + + $ = $ && $.hasOwnProperty('default') ? $['default'] : $; + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; + } + + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + } + + function _objectSpread(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i] != null ? arguments[i] : {}; + var ownKeys = Object.keys(source); + + if (typeof Object.getOwnPropertySymbols === 'function') { + ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { + return Object.getOwnPropertyDescriptor(source, sym).enumerable; + })); + } + + ownKeys.forEach(function (key) { + _defineProperty(target, key, source[key]); + }); + } + + return target; + } + + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + subClass.__proto__ = superClass; + } + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): util.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Util = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Private TransitionEnd Helpers + * ------------------------------------------------------------------------ + */ + var TRANSITION_END = 'transitionend'; + var MAX_UID = 1000000; + var MILLISECONDS_MULTIPLIER = 1000; // Shoutout AngusCroll (https://goo.gl/pxwQGp) + + function toType(obj) { + return {}.toString.call(obj).match(/\s([a-z]+)/i)[1].toLowerCase(); + } + + function getSpecialTransitionEndEvent() { + return { + bindType: TRANSITION_END, + delegateType: TRANSITION_END, + handle: function handle(event) { + if ($$$1(event.target).is(this)) { + return event.handleObj.handler.apply(this, arguments); // eslint-disable-line prefer-rest-params + } + + return undefined; // eslint-disable-line no-undefined + } + }; + } + + function transitionEndEmulator(duration) { + var _this = this; + + var called = false; + $$$1(this).one(Util.TRANSITION_END, function () { + called = true; + }); + setTimeout(function () { + if (!called) { + Util.triggerTransitionEnd(_this); + } + }, duration); + return this; + } + + function setTransitionEndSupport() { + $$$1.fn.emulateTransitionEnd = transitionEndEmulator; + $$$1.event.special[Util.TRANSITION_END] = getSpecialTransitionEndEvent(); + } + /** + * -------------------------------------------------------------------------- + * Public Util Api + * -------------------------------------------------------------------------- + */ + + + var Util = { + TRANSITION_END: 'bsTransitionEnd', + getUID: function getUID(prefix) { + do { + // eslint-disable-next-line no-bitwise + prefix += ~~(Math.random() * MAX_UID); // "~~" acts like a faster Math.floor() here + } while (document.getElementById(prefix)); + + return prefix; + }, + getSelectorFromElement: function getSelectorFromElement(element) { + var selector = element.getAttribute('data-target'); + + if (!selector || selector === '#') { + selector = element.getAttribute('href') || ''; + } + + try { + return document.querySelector(selector) ? selector : null; + } catch (err) { + return null; + } + }, + getTransitionDurationFromElement: function getTransitionDurationFromElement(element) { + if (!element) { + return 0; + } // Get transition-duration of the element + + + var transitionDuration = $$$1(element).css('transition-duration'); + var floatTransitionDuration = parseFloat(transitionDuration); // Return 0 if element or transition duration is not found + + if (!floatTransitionDuration) { + return 0; + } // If multiple durations are defined, take the first + + + transitionDuration = transitionDuration.split(',')[0]; + return parseFloat(transitionDuration) * MILLISECONDS_MULTIPLIER; + }, + reflow: function reflow(element) { + return element.offsetHeight; + }, + triggerTransitionEnd: function triggerTransitionEnd(element) { + $$$1(element).trigger(TRANSITION_END); + }, + // TODO: Remove in v5 + supportsTransitionEnd: function supportsTransitionEnd() { + return Boolean(TRANSITION_END); + }, + isElement: function isElement(obj) { + return (obj[0] || obj).nodeType; + }, + typeCheckConfig: function typeCheckConfig(componentName, config, configTypes) { + for (var property in configTypes) { + if (Object.prototype.hasOwnProperty.call(configTypes, property)) { + var expectedTypes = configTypes[property]; + var value = config[property]; + var valueType = value && Util.isElement(value) ? 'element' : toType(value); + + if (!new RegExp(expectedTypes).test(valueType)) { + throw new Error(componentName.toUpperCase() + ": " + ("Option \"" + property + "\" provided type \"" + valueType + "\" ") + ("but expected type \"" + expectedTypes + "\".")); + } + } + } + } + }; + setTransitionEndSupport(); + return Util; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): alert.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Alert = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'alert'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.alert'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Selector = { + DISMISS: '[data-dismiss="alert"]' + }; + var Event = { + CLOSE: "close" + EVENT_KEY, + CLOSED: "closed" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + ALERT: 'alert', + FADE: 'fade', + SHOW: 'show' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Alert = + /*#__PURE__*/ + function () { + function Alert(element) { + this._element = element; + } // Getters + + + var _proto = Alert.prototype; + + // Public + _proto.close = function close(element) { + var rootElement = this._element; + + if (element) { + rootElement = this._getRootElement(element); + } + + var customEvent = this._triggerCloseEvent(rootElement); + + if (customEvent.isDefaultPrevented()) { + return; + } + + this._removeElement(rootElement); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._element = null; + }; // Private + + + _proto._getRootElement = function _getRootElement(element) { + var selector = Util.getSelectorFromElement(element); + var parent = false; + + if (selector) { + parent = document.querySelector(selector); + } + + if (!parent) { + parent = $$$1(element).closest("." + ClassName.ALERT)[0]; + } + + return parent; + }; + + _proto._triggerCloseEvent = function _triggerCloseEvent(element) { + var closeEvent = $$$1.Event(Event.CLOSE); + $$$1(element).trigger(closeEvent); + return closeEvent; + }; + + _proto._removeElement = function _removeElement(element) { + var _this = this; + + $$$1(element).removeClass(ClassName.SHOW); + + if (!$$$1(element).hasClass(ClassName.FADE)) { + this._destroyElement(element); + + return; + } + + var transitionDuration = Util.getTransitionDurationFromElement(element); + $$$1(element).one(Util.TRANSITION_END, function (event) { + return _this._destroyElement(element, event); + }).emulateTransitionEnd(transitionDuration); + }; + + _proto._destroyElement = function _destroyElement(element) { + $$$1(element).detach().trigger(Event.CLOSED).remove(); + }; // Static + + + Alert._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var $element = $$$1(this); + var data = $element.data(DATA_KEY); + + if (!data) { + data = new Alert(this); + $element.data(DATA_KEY, data); + } + + if (config === 'close') { + data[config](this); + } + }); + }; + + Alert._handleDismiss = function _handleDismiss(alertInstance) { + return function (event) { + if (event) { + event.preventDefault(); + } + + alertInstance.close(this); + }; + }; + + _createClass(Alert, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }]); + + return Alert; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DISMISS, Alert._handleDismiss(new Alert())); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Alert._jQueryInterface; + $$$1.fn[NAME].Constructor = Alert; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Alert._jQueryInterface; + }; + + return Alert; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): button.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Button = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'button'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.button'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ClassName = { + ACTIVE: 'active', + BUTTON: 'btn', + FOCUS: 'focus' + }; + var Selector = { + DATA_TOGGLE_CARROT: '[data-toggle^="button"]', + DATA_TOGGLE: '[data-toggle="buttons"]', + INPUT: 'input', + ACTIVE: '.active', + BUTTON: '.btn' + }; + var Event = { + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY, + FOCUS_BLUR_DATA_API: "focus" + EVENT_KEY + DATA_API_KEY + " " + ("blur" + EVENT_KEY + DATA_API_KEY) + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Button = + /*#__PURE__*/ + function () { + function Button(element) { + this._element = element; + } // Getters + + + var _proto = Button.prototype; + + // Public + _proto.toggle = function toggle() { + var triggerChangeEvent = true; + var addAriaPressed = true; + var rootElement = $$$1(this._element).closest(Selector.DATA_TOGGLE)[0]; + + if (rootElement) { + var input = this._element.querySelector(Selector.INPUT); + + if (input) { + if (input.type === 'radio') { + if (input.checked && this._element.classList.contains(ClassName.ACTIVE)) { + triggerChangeEvent = false; + } else { + var activeElement = rootElement.querySelector(Selector.ACTIVE); + + if (activeElement) { + $$$1(activeElement).removeClass(ClassName.ACTIVE); + } + } + } + + if (triggerChangeEvent) { + if (input.hasAttribute('disabled') || rootElement.hasAttribute('disabled') || input.classList.contains('disabled') || rootElement.classList.contains('disabled')) { + return; + } + + input.checked = !this._element.classList.contains(ClassName.ACTIVE); + $$$1(input).trigger('change'); + } + + input.focus(); + addAriaPressed = false; + } + } + + if (addAriaPressed) { + this._element.setAttribute('aria-pressed', !this._element.classList.contains(ClassName.ACTIVE)); + } + + if (triggerChangeEvent) { + $$$1(this._element).toggleClass(ClassName.ACTIVE); + } + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._element = null; + }; // Static + + + Button._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + if (!data) { + data = new Button(this); + $$$1(this).data(DATA_KEY, data); + } + + if (config === 'toggle') { + data[config](); + } + }); + }; + + _createClass(Button, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }]); + + return Button; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE_CARROT, function (event) { + event.preventDefault(); + var button = event.target; + + if (!$$$1(button).hasClass(ClassName.BUTTON)) { + button = $$$1(button).closest(Selector.BUTTON); + } + + Button._jQueryInterface.call($$$1(button), 'toggle'); + }).on(Event.FOCUS_BLUR_DATA_API, Selector.DATA_TOGGLE_CARROT, function (event) { + var button = $$$1(event.target).closest(Selector.BUTTON)[0]; + $$$1(button).toggleClass(ClassName.FOCUS, /^focus(in)?$/.test(event.type)); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Button._jQueryInterface; + $$$1.fn[NAME].Constructor = Button; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Button._jQueryInterface; + }; + + return Button; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): carousel.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Carousel = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'carousel'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.carousel'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ARROW_LEFT_KEYCODE = 37; // KeyboardEvent.which value for left arrow key + + var ARROW_RIGHT_KEYCODE = 39; // KeyboardEvent.which value for right arrow key + + var TOUCHEVENT_COMPAT_WAIT = 500; // Time for mouse compat events to fire after touch + + var Default = { + interval: 5000, + keyboard: true, + slide: false, + pause: 'hover', + wrap: true + }; + var DefaultType = { + interval: '(number|boolean)', + keyboard: 'boolean', + slide: '(boolean|string)', + pause: '(string|boolean)', + wrap: 'boolean' + }; + var Direction = { + NEXT: 'next', + PREV: 'prev', + LEFT: 'left', + RIGHT: 'right' + }; + var Event = { + SLIDE: "slide" + EVENT_KEY, + SLID: "slid" + EVENT_KEY, + KEYDOWN: "keydown" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY, + TOUCHEND: "touchend" + EVENT_KEY, + LOAD_DATA_API: "load" + EVENT_KEY + DATA_API_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + CAROUSEL: 'carousel', + ACTIVE: 'active', + SLIDE: 'slide', + RIGHT: 'carousel-item-right', + LEFT: 'carousel-item-left', + NEXT: 'carousel-item-next', + PREV: 'carousel-item-prev', + ITEM: 'carousel-item' + }; + var Selector = { + ACTIVE: '.active', + ACTIVE_ITEM: '.active.carousel-item', + ITEM: '.carousel-item', + NEXT_PREV: '.carousel-item-next, .carousel-item-prev', + INDICATORS: '.carousel-indicators', + DATA_SLIDE: '[data-slide], [data-slide-to]', + DATA_RIDE: '[data-ride="carousel"]' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Carousel = + /*#__PURE__*/ + function () { + function Carousel(element, config) { + this._items = null; + this._interval = null; + this._activeElement = null; + this._isPaused = false; + this._isSliding = false; + this.touchTimeout = null; + this._config = this._getConfig(config); + this._element = $$$1(element)[0]; + this._indicatorsElement = this._element.querySelector(Selector.INDICATORS); + + this._addEventListeners(); + } // Getters + + + var _proto = Carousel.prototype; + + // Public + _proto.next = function next() { + if (!this._isSliding) { + this._slide(Direction.NEXT); + } + }; + + _proto.nextWhenVisible = function nextWhenVisible() { + // Don't call next when the page isn't visible + // or the carousel or its parent isn't visible + if (!document.hidden && $$$1(this._element).is(':visible') && $$$1(this._element).css('visibility') !== 'hidden') { + this.next(); + } + }; + + _proto.prev = function prev() { + if (!this._isSliding) { + this._slide(Direction.PREV); + } + }; + + _proto.pause = function pause(event) { + if (!event) { + this._isPaused = true; + } + + if (this._element.querySelector(Selector.NEXT_PREV)) { + Util.triggerTransitionEnd(this._element); + this.cycle(true); + } + + clearInterval(this._interval); + this._interval = null; + }; + + _proto.cycle = function cycle(event) { + if (!event) { + this._isPaused = false; + } + + if (this._interval) { + clearInterval(this._interval); + this._interval = null; + } + + if (this._config.interval && !this._isPaused) { + this._interval = setInterval((document.visibilityState ? this.nextWhenVisible : this.next).bind(this), this._config.interval); + } + }; + + _proto.to = function to(index) { + var _this = this; + + this._activeElement = this._element.querySelector(Selector.ACTIVE_ITEM); + + var activeIndex = this._getItemIndex(this._activeElement); + + if (index > this._items.length - 1 || index < 0) { + return; + } + + if (this._isSliding) { + $$$1(this._element).one(Event.SLID, function () { + return _this.to(index); + }); + return; + } + + if (activeIndex === index) { + this.pause(); + this.cycle(); + return; + } + + var direction = index > activeIndex ? Direction.NEXT : Direction.PREV; + + this._slide(direction, this._items[index]); + }; + + _proto.dispose = function dispose() { + $$$1(this._element).off(EVENT_KEY); + $$$1.removeData(this._element, DATA_KEY); + this._items = null; + this._config = null; + this._element = null; + this._interval = null; + this._isPaused = null; + this._isSliding = null; + this._activeElement = null; + this._indicatorsElement = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._addEventListeners = function _addEventListeners() { + var _this2 = this; + + if (this._config.keyboard) { + $$$1(this._element).on(Event.KEYDOWN, function (event) { + return _this2._keydown(event); + }); + } + + if (this._config.pause === 'hover') { + $$$1(this._element).on(Event.MOUSEENTER, function (event) { + return _this2.pause(event); + }).on(Event.MOUSELEAVE, function (event) { + return _this2.cycle(event); + }); + + if ('ontouchstart' in document.documentElement) { + // If it's a touch-enabled device, mouseenter/leave are fired as + // part of the mouse compatibility events on first tap - the carousel + // would stop cycling until user tapped out of it; + // here, we listen for touchend, explicitly pause the carousel + // (as if it's the second time we tap on it, mouseenter compat event + // is NOT fired) and after a timeout (to allow for mouse compatibility + // events to fire) we explicitly restart cycling + $$$1(this._element).on(Event.TOUCHEND, function () { + _this2.pause(); + + if (_this2.touchTimeout) { + clearTimeout(_this2.touchTimeout); + } + + _this2.touchTimeout = setTimeout(function (event) { + return _this2.cycle(event); + }, TOUCHEVENT_COMPAT_WAIT + _this2._config.interval); + }); + } + } + }; + + _proto._keydown = function _keydown(event) { + if (/input|textarea/i.test(event.target.tagName)) { + return; + } + + switch (event.which) { + case ARROW_LEFT_KEYCODE: + event.preventDefault(); + this.prev(); + break; + + case ARROW_RIGHT_KEYCODE: + event.preventDefault(); + this.next(); + break; + + default: + } + }; + + _proto._getItemIndex = function _getItemIndex(element) { + this._items = element && element.parentNode ? [].slice.call(element.parentNode.querySelectorAll(Selector.ITEM)) : []; + return this._items.indexOf(element); + }; + + _proto._getItemByDirection = function _getItemByDirection(direction, activeElement) { + var isNextDirection = direction === Direction.NEXT; + var isPrevDirection = direction === Direction.PREV; + + var activeIndex = this._getItemIndex(activeElement); + + var lastItemIndex = this._items.length - 1; + var isGoingToWrap = isPrevDirection && activeIndex === 0 || isNextDirection && activeIndex === lastItemIndex; + + if (isGoingToWrap && !this._config.wrap) { + return activeElement; + } + + var delta = direction === Direction.PREV ? -1 : 1; + var itemIndex = (activeIndex + delta) % this._items.length; + return itemIndex === -1 ? this._items[this._items.length - 1] : this._items[itemIndex]; + }; + + _proto._triggerSlideEvent = function _triggerSlideEvent(relatedTarget, eventDirectionName) { + var targetIndex = this._getItemIndex(relatedTarget); + + var fromIndex = this._getItemIndex(this._element.querySelector(Selector.ACTIVE_ITEM)); + + var slideEvent = $$$1.Event(Event.SLIDE, { + relatedTarget: relatedTarget, + direction: eventDirectionName, + from: fromIndex, + to: targetIndex + }); + $$$1(this._element).trigger(slideEvent); + return slideEvent; + }; + + _proto._setActiveIndicatorElement = function _setActiveIndicatorElement(element) { + if (this._indicatorsElement) { + var indicators = [].slice.call(this._indicatorsElement.querySelectorAll(Selector.ACTIVE)); + $$$1(indicators).removeClass(ClassName.ACTIVE); + + var nextIndicator = this._indicatorsElement.children[this._getItemIndex(element)]; + + if (nextIndicator) { + $$$1(nextIndicator).addClass(ClassName.ACTIVE); + } + } + }; + + _proto._slide = function _slide(direction, element) { + var _this3 = this; + + var activeElement = this._element.querySelector(Selector.ACTIVE_ITEM); + + var activeElementIndex = this._getItemIndex(activeElement); + + var nextElement = element || activeElement && this._getItemByDirection(direction, activeElement); + + var nextElementIndex = this._getItemIndex(nextElement); + + var isCycling = Boolean(this._interval); + var directionalClassName; + var orderClassName; + var eventDirectionName; + + if (direction === Direction.NEXT) { + directionalClassName = ClassName.LEFT; + orderClassName = ClassName.NEXT; + eventDirectionName = Direction.LEFT; + } else { + directionalClassName = ClassName.RIGHT; + orderClassName = ClassName.PREV; + eventDirectionName = Direction.RIGHT; + } + + if (nextElement && $$$1(nextElement).hasClass(ClassName.ACTIVE)) { + this._isSliding = false; + return; + } + + var slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName); + + if (slideEvent.isDefaultPrevented()) { + return; + } + + if (!activeElement || !nextElement) { + // Some weirdness is happening, so we bail + return; + } + + this._isSliding = true; + + if (isCycling) { + this.pause(); + } + + this._setActiveIndicatorElement(nextElement); + + var slidEvent = $$$1.Event(Event.SLID, { + relatedTarget: nextElement, + direction: eventDirectionName, + from: activeElementIndex, + to: nextElementIndex + }); + + if ($$$1(this._element).hasClass(ClassName.SLIDE)) { + $$$1(nextElement).addClass(orderClassName); + Util.reflow(nextElement); + $$$1(activeElement).addClass(directionalClassName); + $$$1(nextElement).addClass(directionalClassName); + var transitionDuration = Util.getTransitionDurationFromElement(activeElement); + $$$1(activeElement).one(Util.TRANSITION_END, function () { + $$$1(nextElement).removeClass(directionalClassName + " " + orderClassName).addClass(ClassName.ACTIVE); + $$$1(activeElement).removeClass(ClassName.ACTIVE + " " + orderClassName + " " + directionalClassName); + _this3._isSliding = false; + setTimeout(function () { + return $$$1(_this3._element).trigger(slidEvent); + }, 0); + }).emulateTransitionEnd(transitionDuration); + } else { + $$$1(activeElement).removeClass(ClassName.ACTIVE); + $$$1(nextElement).addClass(ClassName.ACTIVE); + this._isSliding = false; + $$$1(this._element).trigger(slidEvent); + } + + if (isCycling) { + this.cycle(); + } + }; // Static + + + Carousel._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = _objectSpread({}, Default, $$$1(this).data()); + + if (typeof config === 'object') { + _config = _objectSpread({}, _config, config); + } + + var action = typeof config === 'string' ? config : _config.slide; + + if (!data) { + data = new Carousel(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'number') { + data.to(config); + } else if (typeof action === 'string') { + if (typeof data[action] === 'undefined') { + throw new TypeError("No method named \"" + action + "\""); + } + + data[action](); + } else if (_config.interval) { + data.pause(); + data.cycle(); + } + }); + }; + + Carousel._dataApiClickHandler = function _dataApiClickHandler(event) { + var selector = Util.getSelectorFromElement(this); + + if (!selector) { + return; + } + + var target = $$$1(selector)[0]; + + if (!target || !$$$1(target).hasClass(ClassName.CAROUSEL)) { + return; + } + + var config = _objectSpread({}, $$$1(target).data(), $$$1(this).data()); + + var slideIndex = this.getAttribute('data-slide-to'); + + if (slideIndex) { + config.interval = false; + } + + Carousel._jQueryInterface.call($$$1(target), config); + + if (slideIndex) { + $$$1(target).data(DATA_KEY).to(slideIndex); + } + + event.preventDefault(); + }; + + _createClass(Carousel, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Carousel; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_SLIDE, Carousel._dataApiClickHandler); + $$$1(window).on(Event.LOAD_DATA_API, function () { + var carousels = [].slice.call(document.querySelectorAll(Selector.DATA_RIDE)); + + for (var i = 0, len = carousels.length; i < len; i++) { + var $carousel = $$$1(carousels[i]); + + Carousel._jQueryInterface.call($carousel, $carousel.data()); + } + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Carousel._jQueryInterface; + $$$1.fn[NAME].Constructor = Carousel; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Carousel._jQueryInterface; + }; + + return Carousel; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): collapse.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Collapse = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'collapse'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.collapse'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Default = { + toggle: true, + parent: '' + }; + var DefaultType = { + toggle: 'boolean', + parent: '(string|element)' + }; + var Event = { + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + SHOW: 'show', + COLLAPSE: 'collapse', + COLLAPSING: 'collapsing', + COLLAPSED: 'collapsed' + }; + var Dimension = { + WIDTH: 'width', + HEIGHT: 'height' + }; + var Selector = { + ACTIVES: '.show, .collapsing', + DATA_TOGGLE: '[data-toggle="collapse"]' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Collapse = + /*#__PURE__*/ + function () { + function Collapse(element, config) { + this._isTransitioning = false; + this._element = element; + this._config = this._getConfig(config); + this._triggerArray = $$$1.makeArray(document.querySelectorAll("[data-toggle=\"collapse\"][href=\"#" + element.id + "\"]," + ("[data-toggle=\"collapse\"][data-target=\"#" + element.id + "\"]"))); + var toggleList = [].slice.call(document.querySelectorAll(Selector.DATA_TOGGLE)); + + for (var i = 0, len = toggleList.length; i < len; i++) { + var elem = toggleList[i]; + var selector = Util.getSelectorFromElement(elem); + var filterElement = [].slice.call(document.querySelectorAll(selector)).filter(function (foundElem) { + return foundElem === element; + }); + + if (selector !== null && filterElement.length > 0) { + this._selector = selector; + + this._triggerArray.push(elem); + } + } + + this._parent = this._config.parent ? this._getParent() : null; + + if (!this._config.parent) { + this._addAriaAndCollapsedClass(this._element, this._triggerArray); + } + + if (this._config.toggle) { + this.toggle(); + } + } // Getters + + + var _proto = Collapse.prototype; + + // Public + _proto.toggle = function toggle() { + if ($$$1(this._element).hasClass(ClassName.SHOW)) { + this.hide(); + } else { + this.show(); + } + }; + + _proto.show = function show() { + var _this = this; + + if (this._isTransitioning || $$$1(this._element).hasClass(ClassName.SHOW)) { + return; + } + + var actives; + var activesData; + + if (this._parent) { + actives = [].slice.call(this._parent.querySelectorAll(Selector.ACTIVES)).filter(function (elem) { + return elem.getAttribute('data-parent') === _this._config.parent; + }); + + if (actives.length === 0) { + actives = null; + } + } + + if (actives) { + activesData = $$$1(actives).not(this._selector).data(DATA_KEY); + + if (activesData && activesData._isTransitioning) { + return; + } + } + + var startEvent = $$$1.Event(Event.SHOW); + $$$1(this._element).trigger(startEvent); + + if (startEvent.isDefaultPrevented()) { + return; + } + + if (actives) { + Collapse._jQueryInterface.call($$$1(actives).not(this._selector), 'hide'); + + if (!activesData) { + $$$1(actives).data(DATA_KEY, null); + } + } + + var dimension = this._getDimension(); + + $$$1(this._element).removeClass(ClassName.COLLAPSE).addClass(ClassName.COLLAPSING); + this._element.style[dimension] = 0; + + if (this._triggerArray.length) { + $$$1(this._triggerArray).removeClass(ClassName.COLLAPSED).attr('aria-expanded', true); + } + + this.setTransitioning(true); + + var complete = function complete() { + $$$1(_this._element).removeClass(ClassName.COLLAPSING).addClass(ClassName.COLLAPSE).addClass(ClassName.SHOW); + _this._element.style[dimension] = ''; + + _this.setTransitioning(false); + + $$$1(_this._element).trigger(Event.SHOWN); + }; + + var capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1); + var scrollSize = "scroll" + capitalizedDimension; + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + this._element.style[dimension] = this._element[scrollSize] + "px"; + }; + + _proto.hide = function hide() { + var _this2 = this; + + if (this._isTransitioning || !$$$1(this._element).hasClass(ClassName.SHOW)) { + return; + } + + var startEvent = $$$1.Event(Event.HIDE); + $$$1(this._element).trigger(startEvent); + + if (startEvent.isDefaultPrevented()) { + return; + } + + var dimension = this._getDimension(); + + this._element.style[dimension] = this._element.getBoundingClientRect()[dimension] + "px"; + Util.reflow(this._element); + $$$1(this._element).addClass(ClassName.COLLAPSING).removeClass(ClassName.COLLAPSE).removeClass(ClassName.SHOW); + var triggerArrayLength = this._triggerArray.length; + + if (triggerArrayLength > 0) { + for (var i = 0; i < triggerArrayLength; i++) { + var trigger = this._triggerArray[i]; + var selector = Util.getSelectorFromElement(trigger); + + if (selector !== null) { + var $elem = $$$1([].slice.call(document.querySelectorAll(selector))); + + if (!$elem.hasClass(ClassName.SHOW)) { + $$$1(trigger).addClass(ClassName.COLLAPSED).attr('aria-expanded', false); + } + } + } + } + + this.setTransitioning(true); + + var complete = function complete() { + _this2.setTransitioning(false); + + $$$1(_this2._element).removeClass(ClassName.COLLAPSING).addClass(ClassName.COLLAPSE).trigger(Event.HIDDEN); + }; + + this._element.style[dimension] = ''; + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + }; + + _proto.setTransitioning = function setTransitioning(isTransitioning) { + this._isTransitioning = isTransitioning; + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + this._config = null; + this._parent = null; + this._element = null; + this._triggerArray = null; + this._isTransitioning = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + config.toggle = Boolean(config.toggle); // Coerce string values + + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._getDimension = function _getDimension() { + var hasWidth = $$$1(this._element).hasClass(Dimension.WIDTH); + return hasWidth ? Dimension.WIDTH : Dimension.HEIGHT; + }; + + _proto._getParent = function _getParent() { + var _this3 = this; + + var parent = null; + + if (Util.isElement(this._config.parent)) { + parent = this._config.parent; // It's a jQuery object + + if (typeof this._config.parent.jquery !== 'undefined') { + parent = this._config.parent[0]; + } + } else { + parent = document.querySelector(this._config.parent); + } + + var selector = "[data-toggle=\"collapse\"][data-parent=\"" + this._config.parent + "\"]"; + var children = [].slice.call(parent.querySelectorAll(selector)); + $$$1(children).each(function (i, element) { + _this3._addAriaAndCollapsedClass(Collapse._getTargetFromElement(element), [element]); + }); + return parent; + }; + + _proto._addAriaAndCollapsedClass = function _addAriaAndCollapsedClass(element, triggerArray) { + if (element) { + var isOpen = $$$1(element).hasClass(ClassName.SHOW); + + if (triggerArray.length) { + $$$1(triggerArray).toggleClass(ClassName.COLLAPSED, !isOpen).attr('aria-expanded', isOpen); + } + } + }; // Static + + + Collapse._getTargetFromElement = function _getTargetFromElement(element) { + var selector = Util.getSelectorFromElement(element); + return selector ? document.querySelector(selector) : null; + }; + + Collapse._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var $this = $$$1(this); + var data = $this.data(DATA_KEY); + + var _config = _objectSpread({}, Default, $this.data(), typeof config === 'object' && config ? config : {}); + + if (!data && _config.toggle && /show|hide/.test(config)) { + _config.toggle = false; + } + + if (!data) { + data = new Collapse(this, _config); + $this.data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Collapse, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Collapse; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + // preventDefault only for elements (which change the URL) not inside the collapsible element + if (event.currentTarget.tagName === 'A') { + event.preventDefault(); + } + + var $trigger = $$$1(this); + var selector = Util.getSelectorFromElement(this); + var selectors = [].slice.call(document.querySelectorAll(selector)); + $$$1(selectors).each(function () { + var $target = $$$1(this); + var data = $target.data(DATA_KEY); + var config = data ? 'toggle' : $trigger.data(); + + Collapse._jQueryInterface.call($target, config); + }); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Collapse._jQueryInterface; + $$$1.fn[NAME].Constructor = Collapse; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Collapse._jQueryInterface; + }; + + return Collapse; + }($); + + /**! + * @fileOverview Kickass library to create and place poppers near their reference elements. + * @version 1.14.3 + * @license + * Copyright (c) 2016 Federico Zivolo and contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + var isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; + + var longerTimeoutBrowsers = ['Edge', 'Trident', 'Firefox']; + var timeoutDuration = 0; + for (var i = 0; i < longerTimeoutBrowsers.length; i += 1) { + if (isBrowser && navigator.userAgent.indexOf(longerTimeoutBrowsers[i]) >= 0) { + timeoutDuration = 1; + break; + } + } + + function microtaskDebounce(fn) { + var called = false; + return function () { + if (called) { + return; + } + called = true; + window.Promise.resolve().then(function () { + called = false; + fn(); + }); + }; + } + + function taskDebounce(fn) { + var scheduled = false; + return function () { + if (!scheduled) { + scheduled = true; + setTimeout(function () { + scheduled = false; + fn(); + }, timeoutDuration); + } + }; + } + + var supportsMicroTasks = isBrowser && window.Promise; + + /** + * Create a debounced version of a method, that's asynchronously deferred + * but called in the minimum time possible. + * + * @method + * @memberof Popper.Utils + * @argument {Function} fn + * @returns {Function} + */ + var debounce = supportsMicroTasks ? microtaskDebounce : taskDebounce; + + /** + * Check if the given variable is a function + * @method + * @memberof Popper.Utils + * @argument {Any} functionToCheck - variable to check + * @returns {Boolean} answer to: is a function? + */ + function isFunction(functionToCheck) { + var getType = {}; + return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]'; + } + + /** + * Get CSS computed property of the given element + * @method + * @memberof Popper.Utils + * @argument {Eement} element + * @argument {String} property + */ + function getStyleComputedProperty(element, property) { + if (element.nodeType !== 1) { + return []; + } + // NOTE: 1 DOM access here + var css = getComputedStyle(element, null); + return property ? css[property] : css; + } + + /** + * Returns the parentNode or the host of the element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} parent + */ + function getParentNode(element) { + if (element.nodeName === 'HTML') { + return element; + } + return element.parentNode || element.host; + } + + /** + * Returns the scrolling parent of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} scroll parent + */ + function getScrollParent(element) { + // Return body, `getScroll` will take care to get the correct `scrollTop` from it + if (!element) { + return document.body; + } + + switch (element.nodeName) { + case 'HTML': + case 'BODY': + return element.ownerDocument.body; + case '#document': + return element.body; + } + + // Firefox want us to check `-x` and `-y` variations as well + + var _getStyleComputedProp = getStyleComputedProperty(element), + overflow = _getStyleComputedProp.overflow, + overflowX = _getStyleComputedProp.overflowX, + overflowY = _getStyleComputedProp.overflowY; + + if (/(auto|scroll|overlay)/.test(overflow + overflowY + overflowX)) { + return element; + } + + return getScrollParent(getParentNode(element)); + } + + var isIE11 = isBrowser && !!(window.MSInputMethodContext && document.documentMode); + var isIE10 = isBrowser && /MSIE 10/.test(navigator.userAgent); + + /** + * Determines if the browser is Internet Explorer + * @method + * @memberof Popper.Utils + * @param {Number} version to check + * @returns {Boolean} isIE + */ + function isIE(version) { + if (version === 11) { + return isIE11; + } + if (version === 10) { + return isIE10; + } + return isIE11 || isIE10; + } + + /** + * Returns the offset parent of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} offset parent + */ + function getOffsetParent(element) { + if (!element) { + return document.documentElement; + } + + var noOffsetParent = isIE(10) ? document.body : null; + + // NOTE: 1 DOM access here + var offsetParent = element.offsetParent; + // Skip hidden elements which don't have an offsetParent + while (offsetParent === noOffsetParent && element.nextElementSibling) { + offsetParent = (element = element.nextElementSibling).offsetParent; + } + + var nodeName = offsetParent && offsetParent.nodeName; + + if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') { + return element ? element.ownerDocument.documentElement : document.documentElement; + } + + // .offsetParent will return the closest TD or TABLE in case + // no offsetParent is present, I hate this job... + if (['TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 && getStyleComputedProperty(offsetParent, 'position') === 'static') { + return getOffsetParent(offsetParent); + } + + return offsetParent; + } + + function isOffsetContainer(element) { + var nodeName = element.nodeName; + + if (nodeName === 'BODY') { + return false; + } + return nodeName === 'HTML' || getOffsetParent(element.firstElementChild) === element; + } + + /** + * Finds the root node (document, shadowDOM root) of the given element + * @method + * @memberof Popper.Utils + * @argument {Element} node + * @returns {Element} root node + */ + function getRoot(node) { + if (node.parentNode !== null) { + return getRoot(node.parentNode); + } + + return node; + } + + /** + * Finds the offset parent common to the two provided nodes + * @method + * @memberof Popper.Utils + * @argument {Element} element1 + * @argument {Element} element2 + * @returns {Element} common offset parent + */ + function findCommonOffsetParent(element1, element2) { + // This check is needed to avoid errors in case one of the elements isn't defined for any reason + if (!element1 || !element1.nodeType || !element2 || !element2.nodeType) { + return document.documentElement; + } + + // Here we make sure to give as "start" the element that comes first in the DOM + var order = element1.compareDocumentPosition(element2) & Node.DOCUMENT_POSITION_FOLLOWING; + var start = order ? element1 : element2; + var end = order ? element2 : element1; + + // Get common ancestor container + var range = document.createRange(); + range.setStart(start, 0); + range.setEnd(end, 0); + var commonAncestorContainer = range.commonAncestorContainer; + + // Both nodes are inside #document + + if (element1 !== commonAncestorContainer && element2 !== commonAncestorContainer || start.contains(end)) { + if (isOffsetContainer(commonAncestorContainer)) { + return commonAncestorContainer; + } + + return getOffsetParent(commonAncestorContainer); + } + + // one of the nodes is inside shadowDOM, find which one + var element1root = getRoot(element1); + if (element1root.host) { + return findCommonOffsetParent(element1root.host, element2); + } else { + return findCommonOffsetParent(element1, getRoot(element2).host); + } + } + + /** + * Gets the scroll value of the given element in the given side (top and left) + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @argument {String} side `top` or `left` + * @returns {number} amount of scrolled pixels + */ + function getScroll(element) { + var side = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'top'; + + var upperSide = side === 'top' ? 'scrollTop' : 'scrollLeft'; + var nodeName = element.nodeName; + + if (nodeName === 'BODY' || nodeName === 'HTML') { + var html = element.ownerDocument.documentElement; + var scrollingElement = element.ownerDocument.scrollingElement || html; + return scrollingElement[upperSide]; + } + + return element[upperSide]; + } + + /* + * Sum or subtract the element scroll values (left and top) from a given rect object + * @method + * @memberof Popper.Utils + * @param {Object} rect - Rect object you want to change + * @param {HTMLElement} element - The element from the function reads the scroll values + * @param {Boolean} subtract - set to true if you want to subtract the scroll values + * @return {Object} rect - The modifier rect object + */ + function includeScroll(rect, element) { + var subtract = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + var scrollTop = getScroll(element, 'top'); + var scrollLeft = getScroll(element, 'left'); + var modifier = subtract ? -1 : 1; + rect.top += scrollTop * modifier; + rect.bottom += scrollTop * modifier; + rect.left += scrollLeft * modifier; + rect.right += scrollLeft * modifier; + return rect; + } + + /* + * Helper to detect borders of a given element + * @method + * @memberof Popper.Utils + * @param {CSSStyleDeclaration} styles + * Result of `getStyleComputedProperty` on the given element + * @param {String} axis - `x` or `y` + * @return {number} borders - The borders size of the given axis + */ + + function getBordersSize(styles, axis) { + var sideA = axis === 'x' ? 'Left' : 'Top'; + var sideB = sideA === 'Left' ? 'Right' : 'Bottom'; + + return parseFloat(styles['border' + sideA + 'Width'], 10) + parseFloat(styles['border' + sideB + 'Width'], 10); + } + + function getSize(axis, body, html, computedStyle) { + return Math.max(body['offset' + axis], body['scroll' + axis], html['client' + axis], html['offset' + axis], html['scroll' + axis], isIE(10) ? html['offset' + axis] + computedStyle['margin' + (axis === 'Height' ? 'Top' : 'Left')] + computedStyle['margin' + (axis === 'Height' ? 'Bottom' : 'Right')] : 0); + } + + function getWindowSizes() { + var body = document.body; + var html = document.documentElement; + var computedStyle = isIE(10) && getComputedStyle(html); + + return { + height: getSize('Height', body, html, computedStyle), + width: getSize('Width', body, html, computedStyle) + }; + } + + var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + }; + + var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; + }(); + + + + + + var defineProperty = function (obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; + }; + + var _extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; + }; + + /** + * Given element offsets, generate an output similar to getBoundingClientRect + * @method + * @memberof Popper.Utils + * @argument {Object} offsets + * @returns {Object} ClientRect like output + */ + function getClientRect(offsets) { + return _extends({}, offsets, { + right: offsets.left + offsets.width, + bottom: offsets.top + offsets.height + }); + } + + /** + * Get bounding client rect of given element + * @method + * @memberof Popper.Utils + * @param {HTMLElement} element + * @return {Object} client rect + */ + function getBoundingClientRect(element) { + var rect = {}; + + // IE10 10 FIX: Please, don't ask, the element isn't + // considered in DOM in some circumstances... + // This isn't reproducible in IE10 compatibility mode of IE11 + try { + if (isIE(10)) { + rect = element.getBoundingClientRect(); + var scrollTop = getScroll(element, 'top'); + var scrollLeft = getScroll(element, 'left'); + rect.top += scrollTop; + rect.left += scrollLeft; + rect.bottom += scrollTop; + rect.right += scrollLeft; + } else { + rect = element.getBoundingClientRect(); + } + } catch (e) {} + + var result = { + left: rect.left, + top: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top + }; + + // subtract scrollbar size from sizes + var sizes = element.nodeName === 'HTML' ? getWindowSizes() : {}; + var width = sizes.width || element.clientWidth || result.right - result.left; + var height = sizes.height || element.clientHeight || result.bottom - result.top; + + var horizScrollbar = element.offsetWidth - width; + var vertScrollbar = element.offsetHeight - height; + + // if an hypothetical scrollbar is detected, we must be sure it's not a `border` + // we make this check conditional for performance reasons + if (horizScrollbar || vertScrollbar) { + var styles = getStyleComputedProperty(element); + horizScrollbar -= getBordersSize(styles, 'x'); + vertScrollbar -= getBordersSize(styles, 'y'); + + result.width -= horizScrollbar; + result.height -= vertScrollbar; + } + + return getClientRect(result); + } + + function getOffsetRectRelativeToArbitraryNode(children, parent) { + var fixedPosition = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + + var isIE10 = isIE(10); + var isHTML = parent.nodeName === 'HTML'; + var childrenRect = getBoundingClientRect(children); + var parentRect = getBoundingClientRect(parent); + var scrollParent = getScrollParent(children); + + var styles = getStyleComputedProperty(parent); + var borderTopWidth = parseFloat(styles.borderTopWidth, 10); + var borderLeftWidth = parseFloat(styles.borderLeftWidth, 10); + + // In cases where the parent is fixed, we must ignore negative scroll in offset calc + if (fixedPosition && parent.nodeName === 'HTML') { + parentRect.top = Math.max(parentRect.top, 0); + parentRect.left = Math.max(parentRect.left, 0); + } + var offsets = getClientRect({ + top: childrenRect.top - parentRect.top - borderTopWidth, + left: childrenRect.left - parentRect.left - borderLeftWidth, + width: childrenRect.width, + height: childrenRect.height + }); + offsets.marginTop = 0; + offsets.marginLeft = 0; + + // Subtract margins of documentElement in case it's being used as parent + // we do this only on HTML because it's the only element that behaves + // differently when margins are applied to it. The margins are included in + // the box of the documentElement, in the other cases not. + if (!isIE10 && isHTML) { + var marginTop = parseFloat(styles.marginTop, 10); + var marginLeft = parseFloat(styles.marginLeft, 10); + + offsets.top -= borderTopWidth - marginTop; + offsets.bottom -= borderTopWidth - marginTop; + offsets.left -= borderLeftWidth - marginLeft; + offsets.right -= borderLeftWidth - marginLeft; + + // Attach marginTop and marginLeft because in some circumstances we may need them + offsets.marginTop = marginTop; + offsets.marginLeft = marginLeft; + } + + if (isIE10 && !fixedPosition ? parent.contains(scrollParent) : parent === scrollParent && scrollParent.nodeName !== 'BODY') { + offsets = includeScroll(offsets, parent); + } + + return offsets; + } + + function getViewportOffsetRectRelativeToArtbitraryNode(element) { + var excludeScroll = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + var html = element.ownerDocument.documentElement; + var relativeOffset = getOffsetRectRelativeToArbitraryNode(element, html); + var width = Math.max(html.clientWidth, window.innerWidth || 0); + var height = Math.max(html.clientHeight, window.innerHeight || 0); + + var scrollTop = !excludeScroll ? getScroll(html) : 0; + var scrollLeft = !excludeScroll ? getScroll(html, 'left') : 0; + + var offset = { + top: scrollTop - relativeOffset.top + relativeOffset.marginTop, + left: scrollLeft - relativeOffset.left + relativeOffset.marginLeft, + width: width, + height: height + }; + + return getClientRect(offset); + } + + /** + * Check if the given element is fixed or is inside a fixed parent + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @argument {Element} customContainer + * @returns {Boolean} answer to "isFixed?" + */ + function isFixed(element) { + var nodeName = element.nodeName; + if (nodeName === 'BODY' || nodeName === 'HTML') { + return false; + } + if (getStyleComputedProperty(element, 'position') === 'fixed') { + return true; + } + return isFixed(getParentNode(element)); + } + + /** + * Finds the first parent of an element that has a transformed property defined + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Element} first transformed parent or documentElement + */ + + function getFixedPositionOffsetParent(element) { + // This check is needed to avoid errors in case one of the elements isn't defined for any reason + if (!element || !element.parentElement || isIE()) { + return document.documentElement; + } + var el = element.parentElement; + while (el && getStyleComputedProperty(el, 'transform') === 'none') { + el = el.parentElement; + } + return el || document.documentElement; + } + + /** + * Computed the boundaries limits and return them + * @method + * @memberof Popper.Utils + * @param {HTMLElement} popper + * @param {HTMLElement} reference + * @param {number} padding + * @param {HTMLElement} boundariesElement - Element used to define the boundaries + * @param {Boolean} fixedPosition - Is in fixed position mode + * @returns {Object} Coordinates of the boundaries + */ + function getBoundaries(popper, reference, padding, boundariesElement) { + var fixedPosition = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; + + // NOTE: 1 DOM access here + + var boundaries = { top: 0, left: 0 }; + var offsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference); + + // Handle viewport case + if (boundariesElement === 'viewport') { + boundaries = getViewportOffsetRectRelativeToArtbitraryNode(offsetParent, fixedPosition); + } else { + // Handle other cases based on DOM element used as boundaries + var boundariesNode = void 0; + if (boundariesElement === 'scrollParent') { + boundariesNode = getScrollParent(getParentNode(reference)); + if (boundariesNode.nodeName === 'BODY') { + boundariesNode = popper.ownerDocument.documentElement; + } + } else if (boundariesElement === 'window') { + boundariesNode = popper.ownerDocument.documentElement; + } else { + boundariesNode = boundariesElement; + } + + var offsets = getOffsetRectRelativeToArbitraryNode(boundariesNode, offsetParent, fixedPosition); + + // In case of HTML, we need a different computation + if (boundariesNode.nodeName === 'HTML' && !isFixed(offsetParent)) { + var _getWindowSizes = getWindowSizes(), + height = _getWindowSizes.height, + width = _getWindowSizes.width; + + boundaries.top += offsets.top - offsets.marginTop; + boundaries.bottom = height + offsets.top; + boundaries.left += offsets.left - offsets.marginLeft; + boundaries.right = width + offsets.left; + } else { + // for all the other DOM elements, this one is good + boundaries = offsets; + } + } + + // Add paddings + boundaries.left += padding; + boundaries.top += padding; + boundaries.right -= padding; + boundaries.bottom -= padding; + + return boundaries; + } + + function getArea(_ref) { + var width = _ref.width, + height = _ref.height; + + return width * height; + } + + /** + * Utility used to transform the `auto` placement to the placement with more + * available space. + * @method + * @memberof Popper.Utils + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function computeAutoPlacement(placement, refRect, popper, reference, boundariesElement) { + var padding = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; + + if (placement.indexOf('auto') === -1) { + return placement; + } + + var boundaries = getBoundaries(popper, reference, padding, boundariesElement); + + var rects = { + top: { + width: boundaries.width, + height: refRect.top - boundaries.top + }, + right: { + width: boundaries.right - refRect.right, + height: boundaries.height + }, + bottom: { + width: boundaries.width, + height: boundaries.bottom - refRect.bottom + }, + left: { + width: refRect.left - boundaries.left, + height: boundaries.height + } + }; + + var sortedAreas = Object.keys(rects).map(function (key) { + return _extends({ + key: key + }, rects[key], { + area: getArea(rects[key]) + }); + }).sort(function (a, b) { + return b.area - a.area; + }); + + var filteredAreas = sortedAreas.filter(function (_ref2) { + var width = _ref2.width, + height = _ref2.height; + return width >= popper.clientWidth && height >= popper.clientHeight; + }); + + var computedPlacement = filteredAreas.length > 0 ? filteredAreas[0].key : sortedAreas[0].key; + + var variation = placement.split('-')[1]; + + return computedPlacement + (variation ? '-' + variation : ''); + } + + /** + * Get offsets to the reference element + * @method + * @memberof Popper.Utils + * @param {Object} state + * @param {Element} popper - the popper element + * @param {Element} reference - the reference element (the popper will be relative to this) + * @param {Element} fixedPosition - is in fixed position mode + * @returns {Object} An object containing the offsets which will be applied to the popper + */ + function getReferenceOffsets(state, popper, reference) { + var fixedPosition = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + + var commonOffsetParent = fixedPosition ? getFixedPositionOffsetParent(popper) : findCommonOffsetParent(popper, reference); + return getOffsetRectRelativeToArbitraryNode(reference, commonOffsetParent, fixedPosition); + } + + /** + * Get the outer sizes of the given element (offset size + margins) + * @method + * @memberof Popper.Utils + * @argument {Element} element + * @returns {Object} object containing width and height properties + */ + function getOuterSizes(element) { + var styles = getComputedStyle(element); + var x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom); + var y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight); + var result = { + width: element.offsetWidth + y, + height: element.offsetHeight + x + }; + return result; + } + + /** + * Get the opposite placement of the given one + * @method + * @memberof Popper.Utils + * @argument {String} placement + * @returns {String} flipped placement + */ + function getOppositePlacement(placement) { + var hash = { left: 'right', right: 'left', bottom: 'top', top: 'bottom' }; + return placement.replace(/left|right|bottom|top/g, function (matched) { + return hash[matched]; + }); + } + + /** + * Get offsets to the popper + * @method + * @memberof Popper.Utils + * @param {Object} position - CSS position the Popper will get applied + * @param {HTMLElement} popper - the popper element + * @param {Object} referenceOffsets - the reference offsets (the popper will be relative to this) + * @param {String} placement - one of the valid placement options + * @returns {Object} popperOffsets - An object containing the offsets which will be applied to the popper + */ + function getPopperOffsets(popper, referenceOffsets, placement) { + placement = placement.split('-')[0]; + + // Get popper node sizes + var popperRect = getOuterSizes(popper); + + // Add position, width and height to our offsets object + var popperOffsets = { + width: popperRect.width, + height: popperRect.height + }; + + // depending by the popper placement we have to compute its offsets slightly differently + var isHoriz = ['right', 'left'].indexOf(placement) !== -1; + var mainSide = isHoriz ? 'top' : 'left'; + var secondarySide = isHoriz ? 'left' : 'top'; + var measurement = isHoriz ? 'height' : 'width'; + var secondaryMeasurement = !isHoriz ? 'height' : 'width'; + + popperOffsets[mainSide] = referenceOffsets[mainSide] + referenceOffsets[measurement] / 2 - popperRect[measurement] / 2; + if (placement === secondarySide) { + popperOffsets[secondarySide] = referenceOffsets[secondarySide] - popperRect[secondaryMeasurement]; + } else { + popperOffsets[secondarySide] = referenceOffsets[getOppositePlacement(secondarySide)]; + } + + return popperOffsets; + } + + /** + * Mimics the `find` method of Array + * @method + * @memberof Popper.Utils + * @argument {Array} arr + * @argument prop + * @argument value + * @returns index or -1 + */ + function find(arr, check) { + // use native find if supported + if (Array.prototype.find) { + return arr.find(check); + } + + // use `filter` to obtain the same behavior of `find` + return arr.filter(check)[0]; + } + + /** + * Return the index of the matching object + * @method + * @memberof Popper.Utils + * @argument {Array} arr + * @argument prop + * @argument value + * @returns index or -1 + */ + function findIndex(arr, prop, value) { + // use native findIndex if supported + if (Array.prototype.findIndex) { + return arr.findIndex(function (cur) { + return cur[prop] === value; + }); + } + + // use `find` + `indexOf` if `findIndex` isn't supported + var match = find(arr, function (obj) { + return obj[prop] === value; + }); + return arr.indexOf(match); + } + + /** + * Loop trough the list of modifiers and run them in order, + * each of them will then edit the data object. + * @method + * @memberof Popper.Utils + * @param {dataObject} data + * @param {Array} modifiers + * @param {String} ends - Optional modifier name used as stopper + * @returns {dataObject} + */ + function runModifiers(modifiers, data, ends) { + var modifiersToRun = ends === undefined ? modifiers : modifiers.slice(0, findIndex(modifiers, 'name', ends)); + + modifiersToRun.forEach(function (modifier) { + if (modifier['function']) { + // eslint-disable-line dot-notation + console.warn('`modifier.function` is deprecated, use `modifier.fn`!'); + } + var fn = modifier['function'] || modifier.fn; // eslint-disable-line dot-notation + if (modifier.enabled && isFunction(fn)) { + // Add properties to offsets to make them a complete clientRect object + // we do this before each modifier to make sure the previous one doesn't + // mess with these values + data.offsets.popper = getClientRect(data.offsets.popper); + data.offsets.reference = getClientRect(data.offsets.reference); + + data = fn(data, modifier); + } + }); + + return data; + } + + /** + * Updates the position of the popper, computing the new offsets and applying + * the new style.
+ * Prefer `scheduleUpdate` over `update` because of performance reasons. + * @method + * @memberof Popper + */ + function update() { + // if popper is destroyed, don't perform any further update + if (this.state.isDestroyed) { + return; + } + + var data = { + instance: this, + styles: {}, + arrowStyles: {}, + attributes: {}, + flipped: false, + offsets: {} + }; + + // compute reference element offsets + data.offsets.reference = getReferenceOffsets(this.state, this.popper, this.reference, this.options.positionFixed); + + // compute auto placement, store placement inside the data object, + // modifiers will be able to edit `placement` if needed + // and refer to originalPlacement to know the original value + data.placement = computeAutoPlacement(this.options.placement, data.offsets.reference, this.popper, this.reference, this.options.modifiers.flip.boundariesElement, this.options.modifiers.flip.padding); + + // store the computed placement inside `originalPlacement` + data.originalPlacement = data.placement; + + data.positionFixed = this.options.positionFixed; + + // compute the popper offsets + data.offsets.popper = getPopperOffsets(this.popper, data.offsets.reference, data.placement); + + data.offsets.popper.position = this.options.positionFixed ? 'fixed' : 'absolute'; + + // run the modifiers + data = runModifiers(this.modifiers, data); + + // the first `update` will call `onCreate` callback + // the other ones will call `onUpdate` callback + if (!this.state.isCreated) { + this.state.isCreated = true; + this.options.onCreate(data); + } else { + this.options.onUpdate(data); + } + } + + /** + * Helper used to know if the given modifier is enabled. + * @method + * @memberof Popper.Utils + * @returns {Boolean} + */ + function isModifierEnabled(modifiers, modifierName) { + return modifiers.some(function (_ref) { + var name = _ref.name, + enabled = _ref.enabled; + return enabled && name === modifierName; + }); + } + + /** + * Get the prefixed supported property name + * @method + * @memberof Popper.Utils + * @argument {String} property (camelCase) + * @returns {String} prefixed property (camelCase or PascalCase, depending on the vendor prefix) + */ + function getSupportedPropertyName(property) { + var prefixes = [false, 'ms', 'Webkit', 'Moz', 'O']; + var upperProp = property.charAt(0).toUpperCase() + property.slice(1); + + for (var i = 0; i < prefixes.length; i++) { + var prefix = prefixes[i]; + var toCheck = prefix ? '' + prefix + upperProp : property; + if (typeof document.body.style[toCheck] !== 'undefined') { + return toCheck; + } + } + return null; + } + + /** + * Destroy the popper + * @method + * @memberof Popper + */ + function destroy() { + this.state.isDestroyed = true; + + // touch DOM only if `applyStyle` modifier is enabled + if (isModifierEnabled(this.modifiers, 'applyStyle')) { + this.popper.removeAttribute('x-placement'); + this.popper.style.position = ''; + this.popper.style.top = ''; + this.popper.style.left = ''; + this.popper.style.right = ''; + this.popper.style.bottom = ''; + this.popper.style.willChange = ''; + this.popper.style[getSupportedPropertyName('transform')] = ''; + } + + this.disableEventListeners(); + + // remove the popper if user explicity asked for the deletion on destroy + // do not use `remove` because IE11 doesn't support it + if (this.options.removeOnDestroy) { + this.popper.parentNode.removeChild(this.popper); + } + return this; + } + + /** + * Get the window associated with the element + * @argument {Element} element + * @returns {Window} + */ + function getWindow(element) { + var ownerDocument = element.ownerDocument; + return ownerDocument ? ownerDocument.defaultView : window; + } + + function attachToScrollParents(scrollParent, event, callback, scrollParents) { + var isBody = scrollParent.nodeName === 'BODY'; + var target = isBody ? scrollParent.ownerDocument.defaultView : scrollParent; + target.addEventListener(event, callback, { passive: true }); + + if (!isBody) { + attachToScrollParents(getScrollParent(target.parentNode), event, callback, scrollParents); + } + scrollParents.push(target); + } + + /** + * Setup needed event listeners used to update the popper position + * @method + * @memberof Popper.Utils + * @private + */ + function setupEventListeners(reference, options, state, updateBound) { + // Resize event listener on window + state.updateBound = updateBound; + getWindow(reference).addEventListener('resize', state.updateBound, { passive: true }); + + // Scroll event listener on scroll parents + var scrollElement = getScrollParent(reference); + attachToScrollParents(scrollElement, 'scroll', state.updateBound, state.scrollParents); + state.scrollElement = scrollElement; + state.eventsEnabled = true; + + return state; + } + + /** + * It will add resize/scroll events and start recalculating + * position of the popper element when they are triggered. + * @method + * @memberof Popper + */ + function enableEventListeners() { + if (!this.state.eventsEnabled) { + this.state = setupEventListeners(this.reference, this.options, this.state, this.scheduleUpdate); + } + } + + /** + * Remove event listeners used to update the popper position + * @method + * @memberof Popper.Utils + * @private + */ + function removeEventListeners(reference, state) { + // Remove resize event listener on window + getWindow(reference).removeEventListener('resize', state.updateBound); + + // Remove scroll event listener on scroll parents + state.scrollParents.forEach(function (target) { + target.removeEventListener('scroll', state.updateBound); + }); + + // Reset state + state.updateBound = null; + state.scrollParents = []; + state.scrollElement = null; + state.eventsEnabled = false; + return state; + } + + /** + * It will remove resize/scroll events and won't recalculate popper position + * when they are triggered. It also won't trigger onUpdate callback anymore, + * unless you call `update` method manually. + * @method + * @memberof Popper + */ + function disableEventListeners() { + if (this.state.eventsEnabled) { + cancelAnimationFrame(this.scheduleUpdate); + this.state = removeEventListeners(this.reference, this.state); + } + } + + /** + * Tells if a given input is a number + * @method + * @memberof Popper.Utils + * @param {*} input to check + * @return {Boolean} + */ + function isNumeric(n) { + return n !== '' && !isNaN(parseFloat(n)) && isFinite(n); + } + + /** + * Set the style to the given popper + * @method + * @memberof Popper.Utils + * @argument {Element} element - Element to apply the style to + * @argument {Object} styles + * Object with a list of properties and values which will be applied to the element + */ + function setStyles(element, styles) { + Object.keys(styles).forEach(function (prop) { + var unit = ''; + // add unit if the value is numeric and is one of the following + if (['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !== -1 && isNumeric(styles[prop])) { + unit = 'px'; + } + element.style[prop] = styles[prop] + unit; + }); + } + + /** + * Set the attributes to the given popper + * @method + * @memberof Popper.Utils + * @argument {Element} element - Element to apply the attributes to + * @argument {Object} styles + * Object with a list of properties and values which will be applied to the element + */ + function setAttributes(element, attributes) { + Object.keys(attributes).forEach(function (prop) { + var value = attributes[prop]; + if (value !== false) { + element.setAttribute(prop, attributes[prop]); + } else { + element.removeAttribute(prop); + } + }); + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} data.styles - List of style properties - values to apply to popper element + * @argument {Object} data.attributes - List of attribute properties - values to apply to popper element + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The same data object + */ + function applyStyle(data) { + // any property present in `data.styles` will be applied to the popper, + // in this way we can make the 3rd party modifiers add custom styles to it + // Be aware, modifiers could override the properties defined in the previous + // lines of this modifier! + setStyles(data.instance.popper, data.styles); + + // any property present in `data.attributes` will be applied to the popper, + // they will be set as HTML attributes of the element + setAttributes(data.instance.popper, data.attributes); + + // if arrowElement is defined and arrowStyles has some properties + if (data.arrowElement && Object.keys(data.arrowStyles).length) { + setStyles(data.arrowElement, data.arrowStyles); + } + + return data; + } + + /** + * Set the x-placement attribute before everything else because it could be used + * to add margins to the popper margins needs to be calculated to get the + * correct popper offsets. + * @method + * @memberof Popper.modifiers + * @param {HTMLElement} reference - The reference element used to position the popper + * @param {HTMLElement} popper - The HTML element used as popper + * @param {Object} options - Popper.js options + */ + function applyStyleOnLoad(reference, popper, options, modifierOptions, state) { + // compute reference element offsets + var referenceOffsets = getReferenceOffsets(state, popper, reference, options.positionFixed); + + // compute auto placement, store placement inside the data object, + // modifiers will be able to edit `placement` if needed + // and refer to originalPlacement to know the original value + var placement = computeAutoPlacement(options.placement, referenceOffsets, popper, reference, options.modifiers.flip.boundariesElement, options.modifiers.flip.padding); + + popper.setAttribute('x-placement', placement); + + // Apply `position` to popper before anything else because + // without the position applied we can't guarantee correct computations + setStyles(popper, { position: options.positionFixed ? 'fixed' : 'absolute' }); + + return options; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function computeStyle(data, options) { + var x = options.x, + y = options.y; + var popper = data.offsets.popper; + + // Remove this legacy support in Popper.js v2 + + var legacyGpuAccelerationOption = find(data.instance.modifiers, function (modifier) { + return modifier.name === 'applyStyle'; + }).gpuAcceleration; + if (legacyGpuAccelerationOption !== undefined) { + console.warn('WARNING: `gpuAcceleration` option moved to `computeStyle` modifier and will not be supported in future versions of Popper.js!'); + } + var gpuAcceleration = legacyGpuAccelerationOption !== undefined ? legacyGpuAccelerationOption : options.gpuAcceleration; + + var offsetParent = getOffsetParent(data.instance.popper); + var offsetParentRect = getBoundingClientRect(offsetParent); + + // Styles + var styles = { + position: popper.position + }; + + // Avoid blurry text by using full pixel integers. + // For pixel-perfect positioning, top/bottom prefers rounded + // values, while left/right prefers floored values. + var offsets = { + left: Math.floor(popper.left), + top: Math.round(popper.top), + bottom: Math.round(popper.bottom), + right: Math.floor(popper.right) + }; + + var sideA = x === 'bottom' ? 'top' : 'bottom'; + var sideB = y === 'right' ? 'left' : 'right'; + + // if gpuAcceleration is set to `true` and transform is supported, + // we use `translate3d` to apply the position to the popper we + // automatically use the supported prefixed version if needed + var prefixedProperty = getSupportedPropertyName('transform'); + + // now, let's make a step back and look at this code closely (wtf?) + // If the content of the popper grows once it's been positioned, it + // may happen that the popper gets misplaced because of the new content + // overflowing its reference element + // To avoid this problem, we provide two options (x and y), which allow + // the consumer to define the offset origin. + // If we position a popper on top of a reference element, we can set + // `x` to `top` to make the popper grow towards its top instead of + // its bottom. + var left = void 0, + top = void 0; + if (sideA === 'bottom') { + top = -offsetParentRect.height + offsets.bottom; + } else { + top = offsets.top; + } + if (sideB === 'right') { + left = -offsetParentRect.width + offsets.right; + } else { + left = offsets.left; + } + if (gpuAcceleration && prefixedProperty) { + styles[prefixedProperty] = 'translate3d(' + left + 'px, ' + top + 'px, 0)'; + styles[sideA] = 0; + styles[sideB] = 0; + styles.willChange = 'transform'; + } else { + // othwerise, we use the standard `top`, `left`, `bottom` and `right` properties + var invertTop = sideA === 'bottom' ? -1 : 1; + var invertLeft = sideB === 'right' ? -1 : 1; + styles[sideA] = top * invertTop; + styles[sideB] = left * invertLeft; + styles.willChange = sideA + ', ' + sideB; + } + + // Attributes + var attributes = { + 'x-placement': data.placement + }; + + // Update `data` attributes, styles and arrowStyles + data.attributes = _extends({}, attributes, data.attributes); + data.styles = _extends({}, styles, data.styles); + data.arrowStyles = _extends({}, data.offsets.arrow, data.arrowStyles); + + return data; + } + + /** + * Helper used to know if the given modifier depends from another one.
+ * It checks if the needed modifier is listed and enabled. + * @method + * @memberof Popper.Utils + * @param {Array} modifiers - list of modifiers + * @param {String} requestingName - name of requesting modifier + * @param {String} requestedName - name of requested modifier + * @returns {Boolean} + */ + function isModifierRequired(modifiers, requestingName, requestedName) { + var requesting = find(modifiers, function (_ref) { + var name = _ref.name; + return name === requestingName; + }); + + var isRequired = !!requesting && modifiers.some(function (modifier) { + return modifier.name === requestedName && modifier.enabled && modifier.order < requesting.order; + }); + + if (!isRequired) { + var _requesting = '`' + requestingName + '`'; + var requested = '`' + requestedName + '`'; + console.warn(requested + ' modifier is required by ' + _requesting + ' modifier in order to work, be sure to include it before ' + _requesting + '!'); + } + return isRequired; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function arrow(data, options) { + var _data$offsets$arrow; + + // arrow depends on keepTogether in order to work + if (!isModifierRequired(data.instance.modifiers, 'arrow', 'keepTogether')) { + return data; + } + + var arrowElement = options.element; + + // if arrowElement is a string, suppose it's a CSS selector + if (typeof arrowElement === 'string') { + arrowElement = data.instance.popper.querySelector(arrowElement); + + // if arrowElement is not found, don't run the modifier + if (!arrowElement) { + return data; + } + } else { + // if the arrowElement isn't a query selector we must check that the + // provided DOM node is child of its popper node + if (!data.instance.popper.contains(arrowElement)) { + console.warn('WARNING: `arrow.element` must be child of its popper element!'); + return data; + } + } + + var placement = data.placement.split('-')[0]; + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var isVertical = ['left', 'right'].indexOf(placement) !== -1; + + var len = isVertical ? 'height' : 'width'; + var sideCapitalized = isVertical ? 'Top' : 'Left'; + var side = sideCapitalized.toLowerCase(); + var altSide = isVertical ? 'left' : 'top'; + var opSide = isVertical ? 'bottom' : 'right'; + var arrowElementSize = getOuterSizes(arrowElement)[len]; + + // + // extends keepTogether behavior making sure the popper and its + // reference have enough pixels in conjuction + // + + // top/left side + if (reference[opSide] - arrowElementSize < popper[side]) { + data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowElementSize); + } + // bottom/right side + if (reference[side] + arrowElementSize > popper[opSide]) { + data.offsets.popper[side] += reference[side] + arrowElementSize - popper[opSide]; + } + data.offsets.popper = getClientRect(data.offsets.popper); + + // compute center of the popper + var center = reference[side] + reference[len] / 2 - arrowElementSize / 2; + + // Compute the sideValue using the updated popper offsets + // take popper margin in account because we don't have this info available + var css = getStyleComputedProperty(data.instance.popper); + var popperMarginSide = parseFloat(css['margin' + sideCapitalized], 10); + var popperBorderSide = parseFloat(css['border' + sideCapitalized + 'Width'], 10); + var sideValue = center - data.offsets.popper[side] - popperMarginSide - popperBorderSide; + + // prevent arrowElement from being placed not contiguously to its popper + sideValue = Math.max(Math.min(popper[len] - arrowElementSize, sideValue), 0); + + data.arrowElement = arrowElement; + data.offsets.arrow = (_data$offsets$arrow = {}, defineProperty(_data$offsets$arrow, side, Math.round(sideValue)), defineProperty(_data$offsets$arrow, altSide, ''), _data$offsets$arrow); + + return data; + } + + /** + * Get the opposite placement variation of the given one + * @method + * @memberof Popper.Utils + * @argument {String} placement variation + * @returns {String} flipped placement variation + */ + function getOppositeVariation(variation) { + if (variation === 'end') { + return 'start'; + } else if (variation === 'start') { + return 'end'; + } + return variation; + } + + /** + * List of accepted placements to use as values of the `placement` option.
+ * Valid placements are: + * - `auto` + * - `top` + * - `right` + * - `bottom` + * - `left` + * + * Each placement can have a variation from this list: + * - `-start` + * - `-end` + * + * Variations are interpreted easily if you think of them as the left to right + * written languages. Horizontally (`top` and `bottom`), `start` is left and `end` + * is right.
+ * Vertically (`left` and `right`), `start` is top and `end` is bottom. + * + * Some valid examples are: + * - `top-end` (on top of reference, right aligned) + * - `right-start` (on right of reference, top aligned) + * - `bottom` (on bottom, centered) + * - `auto-right` (on the side with more space available, alignment depends by placement) + * + * @static + * @type {Array} + * @enum {String} + * @readonly + * @method placements + * @memberof Popper + */ + var placements = ['auto-start', 'auto', 'auto-end', 'top-start', 'top', 'top-end', 'right-start', 'right', 'right-end', 'bottom-end', 'bottom', 'bottom-start', 'left-end', 'left', 'left-start']; + + // Get rid of `auto` `auto-start` and `auto-end` + var validPlacements = placements.slice(3); + + /** + * Given an initial placement, returns all the subsequent placements + * clockwise (or counter-clockwise). + * + * @method + * @memberof Popper.Utils + * @argument {String} placement - A valid placement (it accepts variations) + * @argument {Boolean} counter - Set to true to walk the placements counterclockwise + * @returns {Array} placements including their variations + */ + function clockwise(placement) { + var counter = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + + var index = validPlacements.indexOf(placement); + var arr = validPlacements.slice(index + 1).concat(validPlacements.slice(0, index)); + return counter ? arr.reverse() : arr; + } + + var BEHAVIORS = { + FLIP: 'flip', + CLOCKWISE: 'clockwise', + COUNTERCLOCKWISE: 'counterclockwise' + }; + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function flip(data, options) { + // if `inner` modifier is enabled, we can't use the `flip` modifier + if (isModifierEnabled(data.instance.modifiers, 'inner')) { + return data; + } + + if (data.flipped && data.placement === data.originalPlacement) { + // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides + return data; + } + + var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, options.boundariesElement, data.positionFixed); + + var placement = data.placement.split('-')[0]; + var placementOpposite = getOppositePlacement(placement); + var variation = data.placement.split('-')[1] || ''; + + var flipOrder = []; + + switch (options.behavior) { + case BEHAVIORS.FLIP: + flipOrder = [placement, placementOpposite]; + break; + case BEHAVIORS.CLOCKWISE: + flipOrder = clockwise(placement); + break; + case BEHAVIORS.COUNTERCLOCKWISE: + flipOrder = clockwise(placement, true); + break; + default: + flipOrder = options.behavior; + } + + flipOrder.forEach(function (step, index) { + if (placement !== step || flipOrder.length === index + 1) { + return data; + } + + placement = data.placement.split('-')[0]; + placementOpposite = getOppositePlacement(placement); + + var popperOffsets = data.offsets.popper; + var refOffsets = data.offsets.reference; + + // using floor because the reference offsets may contain decimals we are not going to consider here + var floor = Math.floor; + var overlapsRef = placement === 'left' && floor(popperOffsets.right) > floor(refOffsets.left) || placement === 'right' && floor(popperOffsets.left) < floor(refOffsets.right) || placement === 'top' && floor(popperOffsets.bottom) > floor(refOffsets.top) || placement === 'bottom' && floor(popperOffsets.top) < floor(refOffsets.bottom); + + var overflowsLeft = floor(popperOffsets.left) < floor(boundaries.left); + var overflowsRight = floor(popperOffsets.right) > floor(boundaries.right); + var overflowsTop = floor(popperOffsets.top) < floor(boundaries.top); + var overflowsBottom = floor(popperOffsets.bottom) > floor(boundaries.bottom); + + var overflowsBoundaries = placement === 'left' && overflowsLeft || placement === 'right' && overflowsRight || placement === 'top' && overflowsTop || placement === 'bottom' && overflowsBottom; + + // flip the variation if required + var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; + var flippedVariation = !!options.flipVariations && (isVertical && variation === 'start' && overflowsLeft || isVertical && variation === 'end' && overflowsRight || !isVertical && variation === 'start' && overflowsTop || !isVertical && variation === 'end' && overflowsBottom); + + if (overlapsRef || overflowsBoundaries || flippedVariation) { + // this boolean to detect any flip loop + data.flipped = true; + + if (overlapsRef || overflowsBoundaries) { + placement = flipOrder[index + 1]; + } + + if (flippedVariation) { + variation = getOppositeVariation(variation); + } + + data.placement = placement + (variation ? '-' + variation : ''); + + // this object contains `position`, we want to preserve it along with + // any additional property we may add in the future + data.offsets.popper = _extends({}, data.offsets.popper, getPopperOffsets(data.instance.popper, data.offsets.reference, data.placement)); + + data = runModifiers(data.instance.modifiers, data, 'flip'); + } + }); + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function keepTogether(data) { + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var placement = data.placement.split('-')[0]; + var floor = Math.floor; + var isVertical = ['top', 'bottom'].indexOf(placement) !== -1; + var side = isVertical ? 'right' : 'bottom'; + var opSide = isVertical ? 'left' : 'top'; + var measurement = isVertical ? 'width' : 'height'; + + if (popper[side] < floor(reference[opSide])) { + data.offsets.popper[opSide] = floor(reference[opSide]) - popper[measurement]; + } + if (popper[opSide] > floor(reference[side])) { + data.offsets.popper[opSide] = floor(reference[side]); + } + + return data; + } + + /** + * Converts a string containing value + unit into a px value number + * @function + * @memberof {modifiers~offset} + * @private + * @argument {String} str - Value + unit string + * @argument {String} measurement - `height` or `width` + * @argument {Object} popperOffsets + * @argument {Object} referenceOffsets + * @returns {Number|String} + * Value in pixels, or original string if no values were extracted + */ + function toValue(str, measurement, popperOffsets, referenceOffsets) { + // separate value from unit + var split = str.match(/((?:\-|\+)?\d*\.?\d*)(.*)/); + var value = +split[1]; + var unit = split[2]; + + // If it's not a number it's an operator, I guess + if (!value) { + return str; + } + + if (unit.indexOf('%') === 0) { + var element = void 0; + switch (unit) { + case '%p': + element = popperOffsets; + break; + case '%': + case '%r': + default: + element = referenceOffsets; + } + + var rect = getClientRect(element); + return rect[measurement] / 100 * value; + } else if (unit === 'vh' || unit === 'vw') { + // if is a vh or vw, we calculate the size based on the viewport + var size = void 0; + if (unit === 'vh') { + size = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + } else { + size = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + } + return size / 100 * value; + } else { + // if is an explicit pixel unit, we get rid of the unit and keep the value + // if is an implicit unit, it's px, and we return just the value + return value; + } + } + + /** + * Parse an `offset` string to extrapolate `x` and `y` numeric offsets. + * @function + * @memberof {modifiers~offset} + * @private + * @argument {String} offset + * @argument {Object} popperOffsets + * @argument {Object} referenceOffsets + * @argument {String} basePlacement + * @returns {Array} a two cells array with x and y offsets in numbers + */ + function parseOffset(offset, popperOffsets, referenceOffsets, basePlacement) { + var offsets = [0, 0]; + + // Use height if placement is left or right and index is 0 otherwise use width + // in this way the first offset will use an axis and the second one + // will use the other one + var useHeight = ['right', 'left'].indexOf(basePlacement) !== -1; + + // Split the offset string to obtain a list of values and operands + // The regex addresses values with the plus or minus sign in front (+10, -20, etc) + var fragments = offset.split(/(\+|\-)/).map(function (frag) { + return frag.trim(); + }); + + // Detect if the offset string contains a pair of values or a single one + // they could be separated by comma or space + var divider = fragments.indexOf(find(fragments, function (frag) { + return frag.search(/,|\s/) !== -1; + })); + + if (fragments[divider] && fragments[divider].indexOf(',') === -1) { + console.warn('Offsets separated by white space(s) are deprecated, use a comma (,) instead.'); + } + + // If divider is found, we divide the list of values and operands to divide + // them by ofset X and Y. + var splitRegex = /\s*,\s*|\s+/; + var ops = divider !== -1 ? [fragments.slice(0, divider).concat([fragments[divider].split(splitRegex)[0]]), [fragments[divider].split(splitRegex)[1]].concat(fragments.slice(divider + 1))] : [fragments]; + + // Convert the values with units to absolute pixels to allow our computations + ops = ops.map(function (op, index) { + // Most of the units rely on the orientation of the popper + var measurement = (index === 1 ? !useHeight : useHeight) ? 'height' : 'width'; + var mergeWithPrevious = false; + return op + // This aggregates any `+` or `-` sign that aren't considered operators + // e.g.: 10 + +5 => [10, +, +5] + .reduce(function (a, b) { + if (a[a.length - 1] === '' && ['+', '-'].indexOf(b) !== -1) { + a[a.length - 1] = b; + mergeWithPrevious = true; + return a; + } else if (mergeWithPrevious) { + a[a.length - 1] += b; + mergeWithPrevious = false; + return a; + } else { + return a.concat(b); + } + }, []) + // Here we convert the string values into number values (in px) + .map(function (str) { + return toValue(str, measurement, popperOffsets, referenceOffsets); + }); + }); + + // Loop trough the offsets arrays and execute the operations + ops.forEach(function (op, index) { + op.forEach(function (frag, index2) { + if (isNumeric(frag)) { + offsets[index] += frag * (op[index2 - 1] === '-' ? -1 : 1); + } + }); + }); + return offsets; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @argument {Number|String} options.offset=0 + * The offset value as described in the modifier description + * @returns {Object} The data object, properly modified + */ + function offset(data, _ref) { + var offset = _ref.offset; + var placement = data.placement, + _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var basePlacement = placement.split('-')[0]; + + var offsets = void 0; + if (isNumeric(+offset)) { + offsets = [+offset, 0]; + } else { + offsets = parseOffset(offset, popper, reference, basePlacement); + } + + if (basePlacement === 'left') { + popper.top += offsets[0]; + popper.left -= offsets[1]; + } else if (basePlacement === 'right') { + popper.top += offsets[0]; + popper.left += offsets[1]; + } else if (basePlacement === 'top') { + popper.left += offsets[0]; + popper.top -= offsets[1]; + } else if (basePlacement === 'bottom') { + popper.left += offsets[0]; + popper.top += offsets[1]; + } + + data.popper = popper; + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function preventOverflow(data, options) { + var boundariesElement = options.boundariesElement || getOffsetParent(data.instance.popper); + + // If offsetParent is the reference element, we really want to + // go one step up and use the next offsetParent as reference to + // avoid to make this modifier completely useless and look like broken + if (data.instance.reference === boundariesElement) { + boundariesElement = getOffsetParent(boundariesElement); + } + + // NOTE: DOM access here + // resets the popper's position so that the document size can be calculated excluding + // the size of the popper element itself + var transformProp = getSupportedPropertyName('transform'); + var popperStyles = data.instance.popper.style; // assignment to help minification + var top = popperStyles.top, + left = popperStyles.left, + transform = popperStyles[transformProp]; + + popperStyles.top = ''; + popperStyles.left = ''; + popperStyles[transformProp] = ''; + + var boundaries = getBoundaries(data.instance.popper, data.instance.reference, options.padding, boundariesElement, data.positionFixed); + + // NOTE: DOM access here + // restores the original style properties after the offsets have been computed + popperStyles.top = top; + popperStyles.left = left; + popperStyles[transformProp] = transform; + + options.boundaries = boundaries; + + var order = options.priority; + var popper = data.offsets.popper; + + var check = { + primary: function primary(placement) { + var value = popper[placement]; + if (popper[placement] < boundaries[placement] && !options.escapeWithReference) { + value = Math.max(popper[placement], boundaries[placement]); + } + return defineProperty({}, placement, value); + }, + secondary: function secondary(placement) { + var mainSide = placement === 'right' ? 'left' : 'top'; + var value = popper[mainSide]; + if (popper[placement] > boundaries[placement] && !options.escapeWithReference) { + value = Math.min(popper[mainSide], boundaries[placement] - (placement === 'right' ? popper.width : popper.height)); + } + return defineProperty({}, mainSide, value); + } + }; + + order.forEach(function (placement) { + var side = ['left', 'top'].indexOf(placement) !== -1 ? 'primary' : 'secondary'; + popper = _extends({}, popper, check[side](placement)); + }); + + data.offsets.popper = popper; + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function shift(data) { + var placement = data.placement; + var basePlacement = placement.split('-')[0]; + var shiftvariation = placement.split('-')[1]; + + // if shift shiftvariation is specified, run the modifier + if (shiftvariation) { + var _data$offsets = data.offsets, + reference = _data$offsets.reference, + popper = _data$offsets.popper; + + var isVertical = ['bottom', 'top'].indexOf(basePlacement) !== -1; + var side = isVertical ? 'left' : 'top'; + var measurement = isVertical ? 'width' : 'height'; + + var shiftOffsets = { + start: defineProperty({}, side, reference[side]), + end: defineProperty({}, side, reference[side] + reference[measurement] - popper[measurement]) + }; + + data.offsets.popper = _extends({}, popper, shiftOffsets[shiftvariation]); + } + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by update method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function hide(data) { + if (!isModifierRequired(data.instance.modifiers, 'hide', 'preventOverflow')) { + return data; + } + + var refRect = data.offsets.reference; + var bound = find(data.instance.modifiers, function (modifier) { + return modifier.name === 'preventOverflow'; + }).boundaries; + + if (refRect.bottom < bound.top || refRect.left > bound.right || refRect.top > bound.bottom || refRect.right < bound.left) { + // Avoid unnecessary DOM access if visibility hasn't changed + if (data.hide === true) { + return data; + } + + data.hide = true; + data.attributes['x-out-of-boundaries'] = ''; + } else { + // Avoid unnecessary DOM access if visibility hasn't changed + if (data.hide === false) { + return data; + } + + data.hide = false; + data.attributes['x-out-of-boundaries'] = false; + } + + return data; + } + + /** + * @function + * @memberof Modifiers + * @argument {Object} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {Object} The data object, properly modified + */ + function inner(data) { + var placement = data.placement; + var basePlacement = placement.split('-')[0]; + var _data$offsets = data.offsets, + popper = _data$offsets.popper, + reference = _data$offsets.reference; + + var isHoriz = ['left', 'right'].indexOf(basePlacement) !== -1; + + var subtractLength = ['top', 'left'].indexOf(basePlacement) === -1; + + popper[isHoriz ? 'left' : 'top'] = reference[basePlacement] - (subtractLength ? popper[isHoriz ? 'width' : 'height'] : 0); + + data.placement = getOppositePlacement(placement); + data.offsets.popper = getClientRect(popper); + + return data; + } + + /** + * Modifier function, each modifier can have a function of this type assigned + * to its `fn` property.
+ * These functions will be called on each update, this means that you must + * make sure they are performant enough to avoid performance bottlenecks. + * + * @function ModifierFn + * @argument {dataObject} data - The data object generated by `update` method + * @argument {Object} options - Modifiers configuration and options + * @returns {dataObject} The data object, properly modified + */ + + /** + * Modifiers are plugins used to alter the behavior of your poppers.
+ * Popper.js uses a set of 9 modifiers to provide all the basic functionalities + * needed by the library. + * + * Usually you don't want to override the `order`, `fn` and `onLoad` props. + * All the other properties are configurations that could be tweaked. + * @namespace modifiers + */ + var modifiers = { + /** + * Modifier used to shift the popper on the start or end of its reference + * element.
+ * It will read the variation of the `placement` property.
+ * It can be one either `-end` or `-start`. + * @memberof modifiers + * @inner + */ + shift: { + /** @prop {number} order=100 - Index used to define the order of execution */ + order: 100, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: shift + }, + + /** + * The `offset` modifier can shift your popper on both its axis. + * + * It accepts the following units: + * - `px` or unitless, interpreted as pixels + * - `%` or `%r`, percentage relative to the length of the reference element + * - `%p`, percentage relative to the length of the popper element + * - `vw`, CSS viewport width unit + * - `vh`, CSS viewport height unit + * + * For length is intended the main axis relative to the placement of the popper.
+ * This means that if the placement is `top` or `bottom`, the length will be the + * `width`. In case of `left` or `right`, it will be the height. + * + * You can provide a single value (as `Number` or `String`), or a pair of values + * as `String` divided by a comma or one (or more) white spaces.
+ * The latter is a deprecated method because it leads to confusion and will be + * removed in v2.
+ * Additionally, it accepts additions and subtractions between different units. + * Note that multiplications and divisions aren't supported. + * + * Valid examples are: + * ``` + * 10 + * '10%' + * '10, 10' + * '10%, 10' + * '10 + 10%' + * '10 - 5vh + 3%' + * '-10px + 5vh, 5px - 6%' + * ``` + * > **NB**: If you desire to apply offsets to your poppers in a way that may make them overlap + * > with their reference element, unfortunately, you will have to disable the `flip` modifier. + * > More on this [reading this issue](https://github.com/FezVrasta/popper.js/issues/373) + * + * @memberof modifiers + * @inner + */ + offset: { + /** @prop {number} order=200 - Index used to define the order of execution */ + order: 200, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: offset, + /** @prop {Number|String} offset=0 + * The offset value as described in the modifier description + */ + offset: 0 + }, + + /** + * Modifier used to prevent the popper from being positioned outside the boundary. + * + * An scenario exists where the reference itself is not within the boundaries.
+ * We can say it has "escaped the boundaries" — or just "escaped".
+ * In this case we need to decide whether the popper should either: + * + * - detach from the reference and remain "trapped" in the boundaries, or + * - if it should ignore the boundary and "escape with its reference" + * + * When `escapeWithReference` is set to`true` and reference is completely + * outside its boundaries, the popper will overflow (or completely leave) + * the boundaries in order to remain attached to the edge of the reference. + * + * @memberof modifiers + * @inner + */ + preventOverflow: { + /** @prop {number} order=300 - Index used to define the order of execution */ + order: 300, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: preventOverflow, + /** + * @prop {Array} [priority=['left','right','top','bottom']] + * Popper will try to prevent overflow following these priorities by default, + * then, it could overflow on the left and on top of the `boundariesElement` + */ + priority: ['left', 'right', 'top', 'bottom'], + /** + * @prop {number} padding=5 + * Amount of pixel used to define a minimum distance between the boundaries + * and the popper this makes sure the popper has always a little padding + * between the edges of its container + */ + padding: 5, + /** + * @prop {String|HTMLElement} boundariesElement='scrollParent' + * Boundaries used by the modifier, can be `scrollParent`, `window`, + * `viewport` or any DOM element. + */ + boundariesElement: 'scrollParent' + }, + + /** + * Modifier used to make sure the reference and its popper stay near eachothers + * without leaving any gap between the two. Expecially useful when the arrow is + * enabled and you want to assure it to point to its reference element. + * It cares only about the first axis, you can still have poppers with margin + * between the popper and its reference element. + * @memberof modifiers + * @inner + */ + keepTogether: { + /** @prop {number} order=400 - Index used to define the order of execution */ + order: 400, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: keepTogether + }, + + /** + * This modifier is used to move the `arrowElement` of the popper to make + * sure it is positioned between the reference element and its popper element. + * It will read the outer size of the `arrowElement` node to detect how many + * pixels of conjuction are needed. + * + * It has no effect if no `arrowElement` is provided. + * @memberof modifiers + * @inner + */ + arrow: { + /** @prop {number} order=500 - Index used to define the order of execution */ + order: 500, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: arrow, + /** @prop {String|HTMLElement} element='[x-arrow]' - Selector or node used as arrow */ + element: '[x-arrow]' + }, + + /** + * Modifier used to flip the popper's placement when it starts to overlap its + * reference element. + * + * Requires the `preventOverflow` modifier before it in order to work. + * + * **NOTE:** this modifier will interrupt the current update cycle and will + * restart it if it detects the need to flip the placement. + * @memberof modifiers + * @inner + */ + flip: { + /** @prop {number} order=600 - Index used to define the order of execution */ + order: 600, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: flip, + /** + * @prop {String|Array} behavior='flip' + * The behavior used to change the popper's placement. It can be one of + * `flip`, `clockwise`, `counterclockwise` or an array with a list of valid + * placements (with optional variations). + */ + behavior: 'flip', + /** + * @prop {number} padding=5 + * The popper will flip if it hits the edges of the `boundariesElement` + */ + padding: 5, + /** + * @prop {String|HTMLElement} boundariesElement='viewport' + * The element which will define the boundaries of the popper position, + * the popper will never be placed outside of the defined boundaries + * (except if keepTogether is enabled) + */ + boundariesElement: 'viewport' + }, + + /** + * Modifier used to make the popper flow toward the inner of the reference element. + * By default, when this modifier is disabled, the popper will be placed outside + * the reference element. + * @memberof modifiers + * @inner + */ + inner: { + /** @prop {number} order=700 - Index used to define the order of execution */ + order: 700, + /** @prop {Boolean} enabled=false - Whether the modifier is enabled or not */ + enabled: false, + /** @prop {ModifierFn} */ + fn: inner + }, + + /** + * Modifier used to hide the popper when its reference element is outside of the + * popper boundaries. It will set a `x-out-of-boundaries` attribute which can + * be used to hide with a CSS selector the popper when its reference is + * out of boundaries. + * + * Requires the `preventOverflow` modifier before it in order to work. + * @memberof modifiers + * @inner + */ + hide: { + /** @prop {number} order=800 - Index used to define the order of execution */ + order: 800, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: hide + }, + + /** + * Computes the style that will be applied to the popper element to gets + * properly positioned. + * + * Note that this modifier will not touch the DOM, it just prepares the styles + * so that `applyStyle` modifier can apply it. This separation is useful + * in case you need to replace `applyStyle` with a custom implementation. + * + * This modifier has `850` as `order` value to maintain backward compatibility + * with previous versions of Popper.js. Expect the modifiers ordering method + * to change in future major versions of the library. + * + * @memberof modifiers + * @inner + */ + computeStyle: { + /** @prop {number} order=850 - Index used to define the order of execution */ + order: 850, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: computeStyle, + /** + * @prop {Boolean} gpuAcceleration=true + * If true, it uses the CSS 3d transformation to position the popper. + * Otherwise, it will use the `top` and `left` properties. + */ + gpuAcceleration: true, + /** + * @prop {string} [x='bottom'] + * Where to anchor the X axis (`bottom` or `top`). AKA X offset origin. + * Change this if your popper should grow in a direction different from `bottom` + */ + x: 'bottom', + /** + * @prop {string} [x='left'] + * Where to anchor the Y axis (`left` or `right`). AKA Y offset origin. + * Change this if your popper should grow in a direction different from `right` + */ + y: 'right' + }, + + /** + * Applies the computed styles to the popper element. + * + * All the DOM manipulations are limited to this modifier. This is useful in case + * you want to integrate Popper.js inside a framework or view library and you + * want to delegate all the DOM manipulations to it. + * + * Note that if you disable this modifier, you must make sure the popper element + * has its position set to `absolute` before Popper.js can do its work! + * + * Just disable this modifier and define you own to achieve the desired effect. + * + * @memberof modifiers + * @inner + */ + applyStyle: { + /** @prop {number} order=900 - Index used to define the order of execution */ + order: 900, + /** @prop {Boolean} enabled=true - Whether the modifier is enabled or not */ + enabled: true, + /** @prop {ModifierFn} */ + fn: applyStyle, + /** @prop {Function} */ + onLoad: applyStyleOnLoad, + /** + * @deprecated since version 1.10.0, the property moved to `computeStyle` modifier + * @prop {Boolean} gpuAcceleration=true + * If true, it uses the CSS 3d transformation to position the popper. + * Otherwise, it will use the `top` and `left` properties. + */ + gpuAcceleration: undefined + } + }; + + /** + * The `dataObject` is an object containing all the informations used by Popper.js + * this object get passed to modifiers and to the `onCreate` and `onUpdate` callbacks. + * @name dataObject + * @property {Object} data.instance The Popper.js instance + * @property {String} data.placement Placement applied to popper + * @property {String} data.originalPlacement Placement originally defined on init + * @property {Boolean} data.flipped True if popper has been flipped by flip modifier + * @property {Boolean} data.hide True if the reference element is out of boundaries, useful to know when to hide the popper. + * @property {HTMLElement} data.arrowElement Node used as arrow by arrow modifier + * @property {Object} data.styles Any CSS property defined here will be applied to the popper, it expects the JavaScript nomenclature (eg. `marginBottom`) + * @property {Object} data.arrowStyles Any CSS property defined here will be applied to the popper arrow, it expects the JavaScript nomenclature (eg. `marginBottom`) + * @property {Object} data.boundaries Offsets of the popper boundaries + * @property {Object} data.offsets The measurements of popper, reference and arrow elements. + * @property {Object} data.offsets.popper `top`, `left`, `width`, `height` values + * @property {Object} data.offsets.reference `top`, `left`, `width`, `height` values + * @property {Object} data.offsets.arrow] `top` and `left` offsets, only one of them will be different from 0 + */ + + /** + * Default options provided to Popper.js constructor.
+ * These can be overriden using the `options` argument of Popper.js.
+ * To override an option, simply pass as 3rd argument an object with the same + * structure of this object, example: + * ``` + * new Popper(ref, pop, { + * modifiers: { + * preventOverflow: { enabled: false } + * } + * }) + * ``` + * @type {Object} + * @static + * @memberof Popper + */ + var Defaults = { + /** + * Popper's placement + * @prop {Popper.placements} placement='bottom' + */ + placement: 'bottom', + + /** + * Set this to true if you want popper to position it self in 'fixed' mode + * @prop {Boolean} positionFixed=false + */ + positionFixed: false, + + /** + * Whether events (resize, scroll) are initially enabled + * @prop {Boolean} eventsEnabled=true + */ + eventsEnabled: true, + + /** + * Set to true if you want to automatically remove the popper when + * you call the `destroy` method. + * @prop {Boolean} removeOnDestroy=false + */ + removeOnDestroy: false, + + /** + * Callback called when the popper is created.
+ * By default, is set to no-op.
+ * Access Popper.js instance with `data.instance`. + * @prop {onCreate} + */ + onCreate: function onCreate() {}, + + /** + * Callback called when the popper is updated, this callback is not called + * on the initialization/creation of the popper, but only on subsequent + * updates.
+ * By default, is set to no-op.
+ * Access Popper.js instance with `data.instance`. + * @prop {onUpdate} + */ + onUpdate: function onUpdate() {}, + + /** + * List of modifiers used to modify the offsets before they are applied to the popper. + * They provide most of the functionalities of Popper.js + * @prop {modifiers} + */ + modifiers: modifiers + }; + + /** + * @callback onCreate + * @param {dataObject} data + */ + + /** + * @callback onUpdate + * @param {dataObject} data + */ + + // Utils + // Methods + var Popper = function () { + /** + * Create a new Popper.js instance + * @class Popper + * @param {HTMLElement|referenceObject} reference - The reference element used to position the popper + * @param {HTMLElement} popper - The HTML element used as popper. + * @param {Object} options - Your custom options to override the ones defined in [Defaults](#defaults) + * @return {Object} instance - The generated Popper.js instance + */ + function Popper(reference, popper) { + var _this = this; + + var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + classCallCheck(this, Popper); + + this.scheduleUpdate = function () { + return requestAnimationFrame(_this.update); + }; + + // make update() debounced, so that it only runs at most once-per-tick + this.update = debounce(this.update.bind(this)); + + // with {} we create a new object with the options inside it + this.options = _extends({}, Popper.Defaults, options); + + // init state + this.state = { + isDestroyed: false, + isCreated: false, + scrollParents: [] + }; + + // get reference and popper elements (allow jQuery wrappers) + this.reference = reference && reference.jquery ? reference[0] : reference; + this.popper = popper && popper.jquery ? popper[0] : popper; + + // Deep merge modifiers options + this.options.modifiers = {}; + Object.keys(_extends({}, Popper.Defaults.modifiers, options.modifiers)).forEach(function (name) { + _this.options.modifiers[name] = _extends({}, Popper.Defaults.modifiers[name] || {}, options.modifiers ? options.modifiers[name] : {}); + }); + + // Refactoring modifiers' list (Object => Array) + this.modifiers = Object.keys(this.options.modifiers).map(function (name) { + return _extends({ + name: name + }, _this.options.modifiers[name]); + }) + // sort the modifiers by order + .sort(function (a, b) { + return a.order - b.order; + }); + + // modifiers have the ability to execute arbitrary code when Popper.js get inited + // such code is executed in the same order of its modifier + // they could add new properties to their options configuration + // BE AWARE: don't add options to `options.modifiers.name` but to `modifierOptions`! + this.modifiers.forEach(function (modifierOptions) { + if (modifierOptions.enabled && isFunction(modifierOptions.onLoad)) { + modifierOptions.onLoad(_this.reference, _this.popper, _this.options, modifierOptions, _this.state); + } + }); + + // fire the first update to position the popper in the right place + this.update(); + + var eventsEnabled = this.options.eventsEnabled; + if (eventsEnabled) { + // setup event listeners, they will take care of update the position in specific situations + this.enableEventListeners(); + } + + this.state.eventsEnabled = eventsEnabled; + } + + // We can't use class properties because they don't get listed in the + // class prototype and break stuff like Sinon stubs + + + createClass(Popper, [{ + key: 'update', + value: function update$$1() { + return update.call(this); + } + }, { + key: 'destroy', + value: function destroy$$1() { + return destroy.call(this); + } + }, { + key: 'enableEventListeners', + value: function enableEventListeners$$1() { + return enableEventListeners.call(this); + } + }, { + key: 'disableEventListeners', + value: function disableEventListeners$$1() { + return disableEventListeners.call(this); + } + + /** + * Schedule an update, it will run on the next UI update available + * @method scheduleUpdate + * @memberof Popper + */ + + + /** + * Collection of utilities useful when writing custom modifiers. + * Starting from version 1.7, this method is available only if you + * include `popper-utils.js` before `popper.js`. + * + * **DEPRECATION**: This way to access PopperUtils is deprecated + * and will be removed in v2! Use the PopperUtils module directly instead. + * Due to the high instability of the methods contained in Utils, we can't + * guarantee them to follow semver. Use them at your own risk! + * @static + * @private + * @type {Object} + * @deprecated since version 1.8 + * @member Utils + * @memberof Popper + */ + + }]); + return Popper; + }(); + + /** + * The `referenceObject` is an object that provides an interface compatible with Popper.js + * and lets you use it as replacement of a real DOM node.
+ * You can use this method to position a popper relatively to a set of coordinates + * in case you don't have a DOM node to use as reference. + * + * ``` + * new Popper(referenceObject, popperNode); + * ``` + * + * NB: This feature isn't supported in Internet Explorer 10 + * @name referenceObject + * @property {Function} data.getBoundingClientRect + * A function that returns a set of coordinates compatible with the native `getBoundingClientRect` method. + * @property {number} data.clientWidth + * An ES6 getter that will return the width of the virtual reference element. + * @property {number} data.clientHeight + * An ES6 getter that will return the height of the virtual reference element. + */ + + + Popper.Utils = (typeof window !== 'undefined' ? window : global).PopperUtils; + Popper.placements = placements; + Popper.Defaults = Defaults; + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): dropdown.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Dropdown = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'dropdown'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.dropdown'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ESCAPE_KEYCODE = 27; // KeyboardEvent.which value for Escape (Esc) key + + var SPACE_KEYCODE = 32; // KeyboardEvent.which value for space key + + var TAB_KEYCODE = 9; // KeyboardEvent.which value for tab key + + var ARROW_UP_KEYCODE = 38; // KeyboardEvent.which value for up arrow key + + var ARROW_DOWN_KEYCODE = 40; // KeyboardEvent.which value for down arrow key + + var RIGHT_MOUSE_BUTTON_WHICH = 3; // MouseEvent.which value for the right button (assuming a right-handed mouse) + + var REGEXP_KEYDOWN = new RegExp(ARROW_UP_KEYCODE + "|" + ARROW_DOWN_KEYCODE + "|" + ESCAPE_KEYCODE); + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY, + KEYDOWN_DATA_API: "keydown" + EVENT_KEY + DATA_API_KEY, + KEYUP_DATA_API: "keyup" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + DISABLED: 'disabled', + SHOW: 'show', + DROPUP: 'dropup', + DROPRIGHT: 'dropright', + DROPLEFT: 'dropleft', + MENURIGHT: 'dropdown-menu-right', + MENULEFT: 'dropdown-menu-left', + POSITION_STATIC: 'position-static' + }; + var Selector = { + DATA_TOGGLE: '[data-toggle="dropdown"]', + FORM_CHILD: '.dropdown form', + MENU: '.dropdown-menu', + NAVBAR_NAV: '.navbar-nav', + VISIBLE_ITEMS: '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' + }; + var AttachmentMap = { + TOP: 'top-start', + TOPEND: 'top-end', + BOTTOM: 'bottom-start', + BOTTOMEND: 'bottom-end', + RIGHT: 'right-start', + RIGHTEND: 'right-end', + LEFT: 'left-start', + LEFTEND: 'left-end' + }; + var Default = { + offset: 0, + flip: true, + boundary: 'scrollParent', + reference: 'toggle', + display: 'dynamic' + }; + var DefaultType = { + offset: '(number|string|function)', + flip: 'boolean', + boundary: '(string|element)', + reference: '(string|element)', + display: 'string' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Dropdown = + /*#__PURE__*/ + function () { + function Dropdown(element, config) { + this._element = element; + this._popper = null; + this._config = this._getConfig(config); + this._menu = this._getMenuElement(); + this._inNavbar = this._detectNavbar(); + + this._addEventListeners(); + } // Getters + + + var _proto = Dropdown.prototype; + + // Public + _proto.toggle = function toggle() { + if (this._element.disabled || $$$1(this._element).hasClass(ClassName.DISABLED)) { + return; + } + + var parent = Dropdown._getParentFromElement(this._element); + + var isActive = $$$1(this._menu).hasClass(ClassName.SHOW); + + Dropdown._clearMenus(); + + if (isActive) { + return; + } + + var relatedTarget = { + relatedTarget: this._element + }; + var showEvent = $$$1.Event(Event.SHOW, relatedTarget); + $$$1(parent).trigger(showEvent); + + if (showEvent.isDefaultPrevented()) { + return; + } // Disable totally Popper.js for Dropdown in Navbar + + + if (!this._inNavbar) { + /** + * Check for Popper dependency + * Popper - https://popper.js.org + */ + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap dropdown require Popper.js (https://popper.js.org)'); + } + + var referenceElement = this._element; + + if (this._config.reference === 'parent') { + referenceElement = parent; + } else if (Util.isElement(this._config.reference)) { + referenceElement = this._config.reference; // Check if it's jQuery element + + if (typeof this._config.reference.jquery !== 'undefined') { + referenceElement = this._config.reference[0]; + } + } // If boundary is not `scrollParent`, then set position to `static` + // to allow the menu to "escape" the scroll parent's boundaries + // https://github.com/twbs/bootstrap/issues/24251 + + + if (this._config.boundary !== 'scrollParent') { + $$$1(parent).addClass(ClassName.POSITION_STATIC); + } + + this._popper = new Popper(referenceElement, this._menu, this._getPopperConfig()); + } // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + + + if ('ontouchstart' in document.documentElement && $$$1(parent).closest(Selector.NAVBAR_NAV).length === 0) { + $$$1(document.body).children().on('mouseover', null, $$$1.noop); + } + + this._element.focus(); + + this._element.setAttribute('aria-expanded', true); + + $$$1(this._menu).toggleClass(ClassName.SHOW); + $$$1(parent).toggleClass(ClassName.SHOW).trigger($$$1.Event(Event.SHOWN, relatedTarget)); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(this._element).off(EVENT_KEY); + this._element = null; + this._menu = null; + + if (this._popper !== null) { + this._popper.destroy(); + + this._popper = null; + } + }; + + _proto.update = function update() { + this._inNavbar = this._detectNavbar(); + + if (this._popper !== null) { + this._popper.scheduleUpdate(); + } + }; // Private + + + _proto._addEventListeners = function _addEventListeners() { + var _this = this; + + $$$1(this._element).on(Event.CLICK, function (event) { + event.preventDefault(); + event.stopPropagation(); + + _this.toggle(); + }); + }; + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, this.constructor.Default, $$$1(this._element).data(), config); + Util.typeCheckConfig(NAME, config, this.constructor.DefaultType); + return config; + }; + + _proto._getMenuElement = function _getMenuElement() { + if (!this._menu) { + var parent = Dropdown._getParentFromElement(this._element); + + if (parent) { + this._menu = parent.querySelector(Selector.MENU); + } + } + + return this._menu; + }; + + _proto._getPlacement = function _getPlacement() { + var $parentDropdown = $$$1(this._element.parentNode); + var placement = AttachmentMap.BOTTOM; // Handle dropup + + if ($parentDropdown.hasClass(ClassName.DROPUP)) { + placement = AttachmentMap.TOP; + + if ($$$1(this._menu).hasClass(ClassName.MENURIGHT)) { + placement = AttachmentMap.TOPEND; + } + } else if ($parentDropdown.hasClass(ClassName.DROPRIGHT)) { + placement = AttachmentMap.RIGHT; + } else if ($parentDropdown.hasClass(ClassName.DROPLEFT)) { + placement = AttachmentMap.LEFT; + } else if ($$$1(this._menu).hasClass(ClassName.MENURIGHT)) { + placement = AttachmentMap.BOTTOMEND; + } + + return placement; + }; + + _proto._detectNavbar = function _detectNavbar() { + return $$$1(this._element).closest('.navbar').length > 0; + }; + + _proto._getPopperConfig = function _getPopperConfig() { + var _this2 = this; + + var offsetConf = {}; + + if (typeof this._config.offset === 'function') { + offsetConf.fn = function (data) { + data.offsets = _objectSpread({}, data.offsets, _this2._config.offset(data.offsets) || {}); + return data; + }; + } else { + offsetConf.offset = this._config.offset; + } + + var popperConfig = { + placement: this._getPlacement(), + modifiers: { + offset: offsetConf, + flip: { + enabled: this._config.flip + }, + preventOverflow: { + boundariesElement: this._config.boundary + } + } // Disable Popper.js if we have a static display + + }; + + if (this._config.display === 'static') { + popperConfig.modifiers.applyStyle = { + enabled: false + }; + } + + return popperConfig; + }; // Static + + + Dropdown._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' ? config : null; + + if (!data) { + data = new Dropdown(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + Dropdown._clearMenus = function _clearMenus(event) { + if (event && (event.which === RIGHT_MOUSE_BUTTON_WHICH || event.type === 'keyup' && event.which !== TAB_KEYCODE)) { + return; + } + + var toggles = [].slice.call(document.querySelectorAll(Selector.DATA_TOGGLE)); + + for (var i = 0, len = toggles.length; i < len; i++) { + var parent = Dropdown._getParentFromElement(toggles[i]); + + var context = $$$1(toggles[i]).data(DATA_KEY); + var relatedTarget = { + relatedTarget: toggles[i] + }; + + if (event && event.type === 'click') { + relatedTarget.clickEvent = event; + } + + if (!context) { + continue; + } + + var dropdownMenu = context._menu; + + if (!$$$1(parent).hasClass(ClassName.SHOW)) { + continue; + } + + if (event && (event.type === 'click' && /input|textarea/i.test(event.target.tagName) || event.type === 'keyup' && event.which === TAB_KEYCODE) && $$$1.contains(parent, event.target)) { + continue; + } + + var hideEvent = $$$1.Event(Event.HIDE, relatedTarget); + $$$1(parent).trigger(hideEvent); + + if (hideEvent.isDefaultPrevented()) { + continue; + } // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().off('mouseover', null, $$$1.noop); + } + + toggles[i].setAttribute('aria-expanded', 'false'); + $$$1(dropdownMenu).removeClass(ClassName.SHOW); + $$$1(parent).removeClass(ClassName.SHOW).trigger($$$1.Event(Event.HIDDEN, relatedTarget)); + } + }; + + Dropdown._getParentFromElement = function _getParentFromElement(element) { + var parent; + var selector = Util.getSelectorFromElement(element); + + if (selector) { + parent = document.querySelector(selector); + } + + return parent || element.parentNode; + }; // eslint-disable-next-line complexity + + + Dropdown._dataApiKeydownHandler = function _dataApiKeydownHandler(event) { + // If not input/textarea: + // - And not a key in REGEXP_KEYDOWN => not a dropdown command + // If input/textarea: + // - If space key => not a dropdown command + // - If key is other than escape + // - If key is not up or down => not a dropdown command + // - If trigger inside the menu => not a dropdown command + if (/input|textarea/i.test(event.target.tagName) ? event.which === SPACE_KEYCODE || event.which !== ESCAPE_KEYCODE && (event.which !== ARROW_DOWN_KEYCODE && event.which !== ARROW_UP_KEYCODE || $$$1(event.target).closest(Selector.MENU).length) : !REGEXP_KEYDOWN.test(event.which)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (this.disabled || $$$1(this).hasClass(ClassName.DISABLED)) { + return; + } + + var parent = Dropdown._getParentFromElement(this); + + var isActive = $$$1(parent).hasClass(ClassName.SHOW); + + if (!isActive && (event.which !== ESCAPE_KEYCODE || event.which !== SPACE_KEYCODE) || isActive && (event.which === ESCAPE_KEYCODE || event.which === SPACE_KEYCODE)) { + if (event.which === ESCAPE_KEYCODE) { + var toggle = parent.querySelector(Selector.DATA_TOGGLE); + $$$1(toggle).trigger('focus'); + } + + $$$1(this).trigger('click'); + return; + } + + var items = [].slice.call(parent.querySelectorAll(Selector.VISIBLE_ITEMS)); + + if (items.length === 0) { + return; + } + + var index = items.indexOf(event.target); + + if (event.which === ARROW_UP_KEYCODE && index > 0) { + // Up + index--; + } + + if (event.which === ARROW_DOWN_KEYCODE && index < items.length - 1) { + // Down + index++; + } + + if (index < 0) { + index = 0; + } + + items[index].focus(); + }; + + _createClass(Dropdown, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Dropdown; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.KEYDOWN_DATA_API, Selector.DATA_TOGGLE, Dropdown._dataApiKeydownHandler).on(Event.KEYDOWN_DATA_API, Selector.MENU, Dropdown._dataApiKeydownHandler).on(Event.CLICK_DATA_API + " " + Event.KEYUP_DATA_API, Dropdown._clearMenus).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + event.preventDefault(); + event.stopPropagation(); + + Dropdown._jQueryInterface.call($$$1(this), 'toggle'); + }).on(Event.CLICK_DATA_API, Selector.FORM_CHILD, function (e) { + e.stopPropagation(); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Dropdown._jQueryInterface; + $$$1.fn[NAME].Constructor = Dropdown; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Dropdown._jQueryInterface; + }; + + return Dropdown; + }($, Popper); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): modal.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Modal = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'modal'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.modal'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var ESCAPE_KEYCODE = 27; // KeyboardEvent.which value for Escape (Esc) key + + var Default = { + backdrop: true, + keyboard: true, + focus: true, + show: true + }; + var DefaultType = { + backdrop: '(boolean|string)', + keyboard: 'boolean', + focus: 'boolean', + show: 'boolean' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + RESIZE: "resize" + EVENT_KEY, + CLICK_DISMISS: "click.dismiss" + EVENT_KEY, + KEYDOWN_DISMISS: "keydown.dismiss" + EVENT_KEY, + MOUSEUP_DISMISS: "mouseup.dismiss" + EVENT_KEY, + MOUSEDOWN_DISMISS: "mousedown.dismiss" + EVENT_KEY, + CLICK_DATA_API: "click" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + SCROLLBAR_MEASURER: 'modal-scrollbar-measure', + BACKDROP: 'modal-backdrop', + OPEN: 'modal-open', + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + DIALOG: '.modal-dialog', + DATA_TOGGLE: '[data-toggle="modal"]', + DATA_DISMISS: '[data-dismiss="modal"]', + FIXED_CONTENT: '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top', + STICKY_CONTENT: '.sticky-top' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Modal = + /*#__PURE__*/ + function () { + function Modal(element, config) { + this._config = this._getConfig(config); + this._element = element; + this._dialog = element.querySelector(Selector.DIALOG); + this._backdrop = null; + this._isShown = false; + this._isBodyOverflowing = false; + this._ignoreBackdropClick = false; + this._scrollbarWidth = 0; + } // Getters + + + var _proto = Modal.prototype; + + // Public + _proto.toggle = function toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget); + }; + + _proto.show = function show(relatedTarget) { + var _this = this; + + if (this._isTransitioning || this._isShown) { + return; + } + + if ($$$1(this._element).hasClass(ClassName.FADE)) { + this._isTransitioning = true; + } + + var showEvent = $$$1.Event(Event.SHOW, { + relatedTarget: relatedTarget + }); + $$$1(this._element).trigger(showEvent); + + if (this._isShown || showEvent.isDefaultPrevented()) { + return; + } + + this._isShown = true; + + this._checkScrollbar(); + + this._setScrollbar(); + + this._adjustDialog(); + + $$$1(document.body).addClass(ClassName.OPEN); + + this._setEscapeEvent(); + + this._setResizeEvent(); + + $$$1(this._element).on(Event.CLICK_DISMISS, Selector.DATA_DISMISS, function (event) { + return _this.hide(event); + }); + $$$1(this._dialog).on(Event.MOUSEDOWN_DISMISS, function () { + $$$1(_this._element).one(Event.MOUSEUP_DISMISS, function (event) { + if ($$$1(event.target).is(_this._element)) { + _this._ignoreBackdropClick = true; + } + }); + }); + + this._showBackdrop(function () { + return _this._showElement(relatedTarget); + }); + }; + + _proto.hide = function hide(event) { + var _this2 = this; + + if (event) { + event.preventDefault(); + } + + if (this._isTransitioning || !this._isShown) { + return; + } + + var hideEvent = $$$1.Event(Event.HIDE); + $$$1(this._element).trigger(hideEvent); + + if (!this._isShown || hideEvent.isDefaultPrevented()) { + return; + } + + this._isShown = false; + var transition = $$$1(this._element).hasClass(ClassName.FADE); + + if (transition) { + this._isTransitioning = true; + } + + this._setEscapeEvent(); + + this._setResizeEvent(); + + $$$1(document).off(Event.FOCUSIN); + $$$1(this._element).removeClass(ClassName.SHOW); + $$$1(this._element).off(Event.CLICK_DISMISS); + $$$1(this._dialog).off(Event.MOUSEDOWN_DISMISS); + + if (transition) { + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._element).one(Util.TRANSITION_END, function (event) { + return _this2._hideModal(event); + }).emulateTransitionEnd(transitionDuration); + } else { + this._hideModal(); + } + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(window, document, this._element, this._backdrop).off(EVENT_KEY); + this._config = null; + this._element = null; + this._dialog = null; + this._backdrop = null; + this._isShown = null; + this._isBodyOverflowing = null; + this._ignoreBackdropClick = null; + this._scrollbarWidth = null; + }; + + _proto.handleUpdate = function handleUpdate() { + this._adjustDialog(); + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, config); + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._showElement = function _showElement(relatedTarget) { + var _this3 = this; + + var transition = $$$1(this._element).hasClass(ClassName.FADE); + + if (!this._element.parentNode || this._element.parentNode.nodeType !== Node.ELEMENT_NODE) { + // Don't move modal's DOM position + document.body.appendChild(this._element); + } + + this._element.style.display = 'block'; + + this._element.removeAttribute('aria-hidden'); + + this._element.scrollTop = 0; + + if (transition) { + Util.reflow(this._element); + } + + $$$1(this._element).addClass(ClassName.SHOW); + + if (this._config.focus) { + this._enforceFocus(); + } + + var shownEvent = $$$1.Event(Event.SHOWN, { + relatedTarget: relatedTarget + }); + + var transitionComplete = function transitionComplete() { + if (_this3._config.focus) { + _this3._element.focus(); + } + + _this3._isTransitioning = false; + $$$1(_this3._element).trigger(shownEvent); + }; + + if (transition) { + var transitionDuration = Util.getTransitionDurationFromElement(this._element); + $$$1(this._dialog).one(Util.TRANSITION_END, transitionComplete).emulateTransitionEnd(transitionDuration); + } else { + transitionComplete(); + } + }; + + _proto._enforceFocus = function _enforceFocus() { + var _this4 = this; + + $$$1(document).off(Event.FOCUSIN) // Guard against infinite focus loop + .on(Event.FOCUSIN, function (event) { + if (document !== event.target && _this4._element !== event.target && $$$1(_this4._element).has(event.target).length === 0) { + _this4._element.focus(); + } + }); + }; + + _proto._setEscapeEvent = function _setEscapeEvent() { + var _this5 = this; + + if (this._isShown && this._config.keyboard) { + $$$1(this._element).on(Event.KEYDOWN_DISMISS, function (event) { + if (event.which === ESCAPE_KEYCODE) { + event.preventDefault(); + + _this5.hide(); + } + }); + } else if (!this._isShown) { + $$$1(this._element).off(Event.KEYDOWN_DISMISS); + } + }; + + _proto._setResizeEvent = function _setResizeEvent() { + var _this6 = this; + + if (this._isShown) { + $$$1(window).on(Event.RESIZE, function (event) { + return _this6.handleUpdate(event); + }); + } else { + $$$1(window).off(Event.RESIZE); + } + }; + + _proto._hideModal = function _hideModal() { + var _this7 = this; + + this._element.style.display = 'none'; + + this._element.setAttribute('aria-hidden', true); + + this._isTransitioning = false; + + this._showBackdrop(function () { + $$$1(document.body).removeClass(ClassName.OPEN); + + _this7._resetAdjustments(); + + _this7._resetScrollbar(); + + $$$1(_this7._element).trigger(Event.HIDDEN); + }); + }; + + _proto._removeBackdrop = function _removeBackdrop() { + if (this._backdrop) { + $$$1(this._backdrop).remove(); + this._backdrop = null; + } + }; + + _proto._showBackdrop = function _showBackdrop(callback) { + var _this8 = this; + + var animate = $$$1(this._element).hasClass(ClassName.FADE) ? ClassName.FADE : ''; + + if (this._isShown && this._config.backdrop) { + this._backdrop = document.createElement('div'); + this._backdrop.className = ClassName.BACKDROP; + + if (animate) { + this._backdrop.classList.add(animate); + } + + $$$1(this._backdrop).appendTo(document.body); + $$$1(this._element).on(Event.CLICK_DISMISS, function (event) { + if (_this8._ignoreBackdropClick) { + _this8._ignoreBackdropClick = false; + return; + } + + if (event.target !== event.currentTarget) { + return; + } + + if (_this8._config.backdrop === 'static') { + _this8._element.focus(); + } else { + _this8.hide(); + } + }); + + if (animate) { + Util.reflow(this._backdrop); + } + + $$$1(this._backdrop).addClass(ClassName.SHOW); + + if (!callback) { + return; + } + + if (!animate) { + callback(); + return; + } + + var backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop); + $$$1(this._backdrop).one(Util.TRANSITION_END, callback).emulateTransitionEnd(backdropTransitionDuration); + } else if (!this._isShown && this._backdrop) { + $$$1(this._backdrop).removeClass(ClassName.SHOW); + + var callbackRemove = function callbackRemove() { + _this8._removeBackdrop(); + + if (callback) { + callback(); + } + }; + + if ($$$1(this._element).hasClass(ClassName.FADE)) { + var _backdropTransitionDuration = Util.getTransitionDurationFromElement(this._backdrop); + + $$$1(this._backdrop).one(Util.TRANSITION_END, callbackRemove).emulateTransitionEnd(_backdropTransitionDuration); + } else { + callbackRemove(); + } + } else if (callback) { + callback(); + } + }; // ---------------------------------------------------------------------- + // the following methods are used to handle overflowing modals + // todo (fat): these should probably be refactored out of modal.js + // ---------------------------------------------------------------------- + + + _proto._adjustDialog = function _adjustDialog() { + var isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight; + + if (!this._isBodyOverflowing && isModalOverflowing) { + this._element.style.paddingLeft = this._scrollbarWidth + "px"; + } + + if (this._isBodyOverflowing && !isModalOverflowing) { + this._element.style.paddingRight = this._scrollbarWidth + "px"; + } + }; + + _proto._resetAdjustments = function _resetAdjustments() { + this._element.style.paddingLeft = ''; + this._element.style.paddingRight = ''; + }; + + _proto._checkScrollbar = function _checkScrollbar() { + var rect = document.body.getBoundingClientRect(); + this._isBodyOverflowing = rect.left + rect.right < window.innerWidth; + this._scrollbarWidth = this._getScrollbarWidth(); + }; + + _proto._setScrollbar = function _setScrollbar() { + var _this9 = this; + + if (this._isBodyOverflowing) { + // Note: DOMNode.style.paddingRight returns the actual value or '' if not set + // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set + var fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)); + var stickyContent = [].slice.call(document.querySelectorAll(Selector.STICKY_CONTENT)); // Adjust fixed content padding + + $$$1(fixedContent).each(function (index, element) { + var actualPadding = element.style.paddingRight; + var calculatedPadding = $$$1(element).css('padding-right'); + $$$1(element).data('padding-right', actualPadding).css('padding-right', parseFloat(calculatedPadding) + _this9._scrollbarWidth + "px"); + }); // Adjust sticky content margin + + $$$1(stickyContent).each(function (index, element) { + var actualMargin = element.style.marginRight; + var calculatedMargin = $$$1(element).css('margin-right'); + $$$1(element).data('margin-right', actualMargin).css('margin-right', parseFloat(calculatedMargin) - _this9._scrollbarWidth + "px"); + }); // Adjust body padding + + var actualPadding = document.body.style.paddingRight; + var calculatedPadding = $$$1(document.body).css('padding-right'); + $$$1(document.body).data('padding-right', actualPadding).css('padding-right', parseFloat(calculatedPadding) + this._scrollbarWidth + "px"); + } + }; + + _proto._resetScrollbar = function _resetScrollbar() { + // Restore fixed content padding + var fixedContent = [].slice.call(document.querySelectorAll(Selector.FIXED_CONTENT)); + $$$1(fixedContent).each(function (index, element) { + var padding = $$$1(element).data('padding-right'); + $$$1(element).removeData('padding-right'); + element.style.paddingRight = padding ? padding : ''; + }); // Restore sticky content + + var elements = [].slice.call(document.querySelectorAll("" + Selector.STICKY_CONTENT)); + $$$1(elements).each(function (index, element) { + var margin = $$$1(element).data('margin-right'); + + if (typeof margin !== 'undefined') { + $$$1(element).css('margin-right', margin).removeData('margin-right'); + } + }); // Restore body padding + + var padding = $$$1(document.body).data('padding-right'); + $$$1(document.body).removeData('padding-right'); + document.body.style.paddingRight = padding ? padding : ''; + }; + + _proto._getScrollbarWidth = function _getScrollbarWidth() { + // thx d.walsh + var scrollDiv = document.createElement('div'); + scrollDiv.className = ClassName.SCROLLBAR_MEASURER; + document.body.appendChild(scrollDiv); + var scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + return scrollbarWidth; + }; // Static + + + Modal._jQueryInterface = function _jQueryInterface(config, relatedTarget) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = _objectSpread({}, Default, $$$1(this).data(), typeof config === 'object' && config ? config : {}); + + if (!data) { + data = new Modal(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](relatedTarget); + } else if (_config.show) { + data.show(relatedTarget); + } + }); + }; + + _createClass(Modal, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }]); + + return Modal; + }(); + /** + * ------------------------------------------------------------------------ + * Data Api implementation + * ------------------------------------------------------------------------ + */ + + + $$$1(document).on(Event.CLICK_DATA_API, Selector.DATA_TOGGLE, function (event) { + var _this10 = this; + + var target; + var selector = Util.getSelectorFromElement(this); + + if (selector) { + target = document.querySelector(selector); + } + + var config = $$$1(target).data(DATA_KEY) ? 'toggle' : _objectSpread({}, $$$1(target).data(), $$$1(this).data()); + + if (this.tagName === 'A' || this.tagName === 'AREA') { + event.preventDefault(); + } + + var $target = $$$1(target).one(Event.SHOW, function (showEvent) { + if (showEvent.isDefaultPrevented()) { + // Only register focus restorer if modal will actually get shown + return; + } + + $target.one(Event.HIDDEN, function () { + if ($$$1(_this10).is(':visible')) { + _this10.focus(); + } + }); + }); + + Modal._jQueryInterface.call($$$1(target), config, this); + }); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + $$$1.fn[NAME] = Modal._jQueryInterface; + $$$1.fn[NAME].Constructor = Modal; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Modal._jQueryInterface; + }; + + return Modal; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): tooltip.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Tooltip = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'tooltip'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.tooltip'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var CLASS_PREFIX = 'bs-tooltip'; + var BSCLS_PREFIX_REGEX = new RegExp("(^|\\s)" + CLASS_PREFIX + "\\S+", 'g'); + var DefaultType = { + animation: 'boolean', + template: 'string', + title: '(string|element|function)', + trigger: 'string', + delay: '(number|object)', + html: 'boolean', + selector: '(string|boolean)', + placement: '(string|function)', + offset: '(number|string)', + container: '(string|element|boolean)', + fallbackPlacement: '(string|array)', + boundary: '(string|element)' + }; + var AttachmentMap = { + AUTO: 'auto', + TOP: 'top', + RIGHT: 'right', + BOTTOM: 'bottom', + LEFT: 'left' + }; + var Default = { + animation: true, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + selector: false, + placement: 'top', + offset: 0, + container: false, + fallbackPlacement: 'flip', + boundary: 'scrollParent' + }; + var HoverState = { + SHOW: 'show', + OUT: 'out' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + INSERTED: "inserted" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + FOCUSOUT: "focusout" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY + }; + var ClassName = { + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + TOOLTIP: '.tooltip', + TOOLTIP_INNER: '.tooltip-inner', + ARROW: '.arrow' + }; + var Trigger = { + HOVER: 'hover', + FOCUS: 'focus', + CLICK: 'click', + MANUAL: 'manual' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Tooltip = + /*#__PURE__*/ + function () { + function Tooltip(element, config) { + /** + * Check for Popper dependency + * Popper - https://popper.js.org + */ + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap tooltips require Popper.js (https://popper.js.org)'); + } // private + + + this._isEnabled = true; + this._timeout = 0; + this._hoverState = ''; + this._activeTrigger = {}; + this._popper = null; // Protected + + this.element = element; + this.config = this._getConfig(config); + this.tip = null; + + this._setListeners(); + } // Getters + + + var _proto = Tooltip.prototype; + + // Public + _proto.enable = function enable() { + this._isEnabled = true; + }; + + _proto.disable = function disable() { + this._isEnabled = false; + }; + + _proto.toggleEnabled = function toggleEnabled() { + this._isEnabled = !this._isEnabled; + }; + + _proto.toggle = function toggle(event) { + if (!this._isEnabled) { + return; + } + + if (event) { + var dataKey = this.constructor.DATA_KEY; + var context = $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + context._activeTrigger.click = !context._activeTrigger.click; + + if (context._isWithActiveTrigger()) { + context._enter(null, context); + } else { + context._leave(null, context); + } + } else { + if ($$$1(this.getTipElement()).hasClass(ClassName.SHOW)) { + this._leave(null, this); + + return; + } + + this._enter(null, this); + } + }; + + _proto.dispose = function dispose() { + clearTimeout(this._timeout); + $$$1.removeData(this.element, this.constructor.DATA_KEY); + $$$1(this.element).off(this.constructor.EVENT_KEY); + $$$1(this.element).closest('.modal').off('hide.bs.modal'); + + if (this.tip) { + $$$1(this.tip).remove(); + } + + this._isEnabled = null; + this._timeout = null; + this._hoverState = null; + this._activeTrigger = null; + + if (this._popper !== null) { + this._popper.destroy(); + } + + this._popper = null; + this.element = null; + this.config = null; + this.tip = null; + }; + + _proto.show = function show() { + var _this = this; + + if ($$$1(this.element).css('display') === 'none') { + throw new Error('Please use show on visible elements'); + } + + var showEvent = $$$1.Event(this.constructor.Event.SHOW); + + if (this.isWithContent() && this._isEnabled) { + $$$1(this.element).trigger(showEvent); + var isInTheDom = $$$1.contains(this.element.ownerDocument.documentElement, this.element); + + if (showEvent.isDefaultPrevented() || !isInTheDom) { + return; + } + + var tip = this.getTipElement(); + var tipId = Util.getUID(this.constructor.NAME); + tip.setAttribute('id', tipId); + this.element.setAttribute('aria-describedby', tipId); + this.setContent(); + + if (this.config.animation) { + $$$1(tip).addClass(ClassName.FADE); + } + + var placement = typeof this.config.placement === 'function' ? this.config.placement.call(this, tip, this.element) : this.config.placement; + + var attachment = this._getAttachment(placement); + + this.addAttachmentClass(attachment); + var container = this.config.container === false ? document.body : $$$1(document).find(this.config.container); + $$$1(tip).data(this.constructor.DATA_KEY, this); + + if (!$$$1.contains(this.element.ownerDocument.documentElement, this.tip)) { + $$$1(tip).appendTo(container); + } + + $$$1(this.element).trigger(this.constructor.Event.INSERTED); + this._popper = new Popper(this.element, tip, { + placement: attachment, + modifiers: { + offset: { + offset: this.config.offset + }, + flip: { + behavior: this.config.fallbackPlacement + }, + arrow: { + element: Selector.ARROW + }, + preventOverflow: { + boundariesElement: this.config.boundary + } + }, + onCreate: function onCreate(data) { + if (data.originalPlacement !== data.placement) { + _this._handlePopperPlacementChange(data); + } + }, + onUpdate: function onUpdate(data) { + _this._handlePopperPlacementChange(data); + } + }); + $$$1(tip).addClass(ClassName.SHOW); // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().on('mouseover', null, $$$1.noop); + } + + var complete = function complete() { + if (_this.config.animation) { + _this._fixTransition(); + } + + var prevHoverState = _this._hoverState; + _this._hoverState = null; + $$$1(_this.element).trigger(_this.constructor.Event.SHOWN); + + if (prevHoverState === HoverState.OUT) { + _this._leave(null, _this); + } + }; + + if ($$$1(this.tip).hasClass(ClassName.FADE)) { + var transitionDuration = Util.getTransitionDurationFromElement(this.tip); + $$$1(this.tip).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + } else { + complete(); + } + } + }; + + _proto.hide = function hide(callback) { + var _this2 = this; + + var tip = this.getTipElement(); + var hideEvent = $$$1.Event(this.constructor.Event.HIDE); + + var complete = function complete() { + if (_this2._hoverState !== HoverState.SHOW && tip.parentNode) { + tip.parentNode.removeChild(tip); + } + + _this2._cleanTipClass(); + + _this2.element.removeAttribute('aria-describedby'); + + $$$1(_this2.element).trigger(_this2.constructor.Event.HIDDEN); + + if (_this2._popper !== null) { + _this2._popper.destroy(); + } + + if (callback) { + callback(); + } + }; + + $$$1(this.element).trigger(hideEvent); + + if (hideEvent.isDefaultPrevented()) { + return; + } + + $$$1(tip).removeClass(ClassName.SHOW); // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + + if ('ontouchstart' in document.documentElement) { + $$$1(document.body).children().off('mouseover', null, $$$1.noop); + } + + this._activeTrigger[Trigger.CLICK] = false; + this._activeTrigger[Trigger.FOCUS] = false; + this._activeTrigger[Trigger.HOVER] = false; + + if ($$$1(this.tip).hasClass(ClassName.FADE)) { + var transitionDuration = Util.getTransitionDurationFromElement(tip); + $$$1(tip).one(Util.TRANSITION_END, complete).emulateTransitionEnd(transitionDuration); + } else { + complete(); + } + + this._hoverState = ''; + }; + + _proto.update = function update() { + if (this._popper !== null) { + this._popper.scheduleUpdate(); + } + }; // Protected + + + _proto.isWithContent = function isWithContent() { + return Boolean(this.getTitle()); + }; + + _proto.addAttachmentClass = function addAttachmentClass(attachment) { + $$$1(this.getTipElement()).addClass(CLASS_PREFIX + "-" + attachment); + }; + + _proto.getTipElement = function getTipElement() { + this.tip = this.tip || $$$1(this.config.template)[0]; + return this.tip; + }; + + _proto.setContent = function setContent() { + var tip = this.getTipElement(); + this.setElementContent($$$1(tip.querySelectorAll(Selector.TOOLTIP_INNER)), this.getTitle()); + $$$1(tip).removeClass(ClassName.FADE + " " + ClassName.SHOW); + }; + + _proto.setElementContent = function setElementContent($element, content) { + var html = this.config.html; + + if (typeof content === 'object' && (content.nodeType || content.jquery)) { + // Content is a DOM node or a jQuery + if (html) { + if (!$$$1(content).parent().is($element)) { + $element.empty().append(content); + } + } else { + $element.text($$$1(content).text()); + } + } else { + $element[html ? 'html' : 'text'](content); + } + }; + + _proto.getTitle = function getTitle() { + var title = this.element.getAttribute('data-original-title'); + + if (!title) { + title = typeof this.config.title === 'function' ? this.config.title.call(this.element) : this.config.title; + } + + return title; + }; // Private + + + _proto._getAttachment = function _getAttachment(placement) { + return AttachmentMap[placement.toUpperCase()]; + }; + + _proto._setListeners = function _setListeners() { + var _this3 = this; + + var triggers = this.config.trigger.split(' '); + triggers.forEach(function (trigger) { + if (trigger === 'click') { + $$$1(_this3.element).on(_this3.constructor.Event.CLICK, _this3.config.selector, function (event) { + return _this3.toggle(event); + }); + } else if (trigger !== Trigger.MANUAL) { + var eventIn = trigger === Trigger.HOVER ? _this3.constructor.Event.MOUSEENTER : _this3.constructor.Event.FOCUSIN; + var eventOut = trigger === Trigger.HOVER ? _this3.constructor.Event.MOUSELEAVE : _this3.constructor.Event.FOCUSOUT; + $$$1(_this3.element).on(eventIn, _this3.config.selector, function (event) { + return _this3._enter(event); + }).on(eventOut, _this3.config.selector, function (event) { + return _this3._leave(event); + }); + } + + $$$1(_this3.element).closest('.modal').on('hide.bs.modal', function () { + return _this3.hide(); + }); + }); + + if (this.config.selector) { + this.config = _objectSpread({}, this.config, { + trigger: 'manual', + selector: '' + }); + } else { + this._fixTitle(); + } + }; + + _proto._fixTitle = function _fixTitle() { + var titleType = typeof this.element.getAttribute('data-original-title'); + + if (this.element.getAttribute('title') || titleType !== 'string') { + this.element.setAttribute('data-original-title', this.element.getAttribute('title') || ''); + this.element.setAttribute('title', ''); + } + }; + + _proto._enter = function _enter(event, context) { + var dataKey = this.constructor.DATA_KEY; + context = context || $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + if (event) { + context._activeTrigger[event.type === 'focusin' ? Trigger.FOCUS : Trigger.HOVER] = true; + } + + if ($$$1(context.getTipElement()).hasClass(ClassName.SHOW) || context._hoverState === HoverState.SHOW) { + context._hoverState = HoverState.SHOW; + return; + } + + clearTimeout(context._timeout); + context._hoverState = HoverState.SHOW; + + if (!context.config.delay || !context.config.delay.show) { + context.show(); + return; + } + + context._timeout = setTimeout(function () { + if (context._hoverState === HoverState.SHOW) { + context.show(); + } + }, context.config.delay.show); + }; + + _proto._leave = function _leave(event, context) { + var dataKey = this.constructor.DATA_KEY; + context = context || $$$1(event.currentTarget).data(dataKey); + + if (!context) { + context = new this.constructor(event.currentTarget, this._getDelegateConfig()); + $$$1(event.currentTarget).data(dataKey, context); + } + + if (event) { + context._activeTrigger[event.type === 'focusout' ? Trigger.FOCUS : Trigger.HOVER] = false; + } + + if (context._isWithActiveTrigger()) { + return; + } + + clearTimeout(context._timeout); + context._hoverState = HoverState.OUT; + + if (!context.config.delay || !context.config.delay.hide) { + context.hide(); + return; + } + + context._timeout = setTimeout(function () { + if (context._hoverState === HoverState.OUT) { + context.hide(); + } + }, context.config.delay.hide); + }; + + _proto._isWithActiveTrigger = function _isWithActiveTrigger() { + for (var trigger in this._activeTrigger) { + if (this._activeTrigger[trigger]) { + return true; + } + } + + return false; + }; + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, this.constructor.Default, $$$1(this.element).data(), typeof config === 'object' && config ? config : {}); + + if (typeof config.delay === 'number') { + config.delay = { + show: config.delay, + hide: config.delay + }; + } + + if (typeof config.title === 'number') { + config.title = config.title.toString(); + } + + if (typeof config.content === 'number') { + config.content = config.content.toString(); + } + + Util.typeCheckConfig(NAME, config, this.constructor.DefaultType); + return config; + }; + + _proto._getDelegateConfig = function _getDelegateConfig() { + var config = {}; + + if (this.config) { + for (var key in this.config) { + if (this.constructor.Default[key] !== this.config[key]) { + config[key] = this.config[key]; + } + } + } + + return config; + }; + + _proto._cleanTipClass = function _cleanTipClass() { + var $tip = $$$1(this.getTipElement()); + var tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX); + + if (tabClass !== null && tabClass.length) { + $tip.removeClass(tabClass.join('')); + } + }; + + _proto._handlePopperPlacementChange = function _handlePopperPlacementChange(popperData) { + var popperInstance = popperData.instance; + this.tip = popperInstance.popper; + + this._cleanTipClass(); + + this.addAttachmentClass(this._getAttachment(popperData.placement)); + }; + + _proto._fixTransition = function _fixTransition() { + var tip = this.getTipElement(); + var initConfigAnimation = this.config.animation; + + if (tip.getAttribute('x-placement') !== null) { + return; + } + + $$$1(tip).removeClass(ClassName.FADE); + this.config.animation = false; + this.hide(); + this.show(); + this.config.animation = initConfigAnimation; + }; // Static + + + Tooltip._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' && config; + + if (!data && /dispose|hide/.test(config)) { + return; + } + + if (!data) { + data = new Tooltip(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Tooltip, null, [{ + key: "VERSION", + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "NAME", + get: function get() { + return NAME; + } + }, { + key: "DATA_KEY", + get: function get() { + return DATA_KEY; + } + }, { + key: "Event", + get: function get() { + return Event; + } + }, { + key: "EVENT_KEY", + get: function get() { + return EVENT_KEY; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Tooltip; + }(); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + + $$$1.fn[NAME] = Tooltip._jQueryInterface; + $$$1.fn[NAME].Constructor = Tooltip; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Tooltip._jQueryInterface; + }; + + return Tooltip; + }($, Popper); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): popover.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var Popover = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'popover'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.popover'; + var EVENT_KEY = "." + DATA_KEY; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var CLASS_PREFIX = 'bs-popover'; + var BSCLS_PREFIX_REGEX = new RegExp("(^|\\s)" + CLASS_PREFIX + "\\S+", 'g'); + + var Default = _objectSpread({}, Tooltip.Default, { + placement: 'right', + trigger: 'click', + content: '', + template: '' + }); + + var DefaultType = _objectSpread({}, Tooltip.DefaultType, { + content: '(string|element|function)' + }); + + var ClassName = { + FADE: 'fade', + SHOW: 'show' + }; + var Selector = { + TITLE: '.popover-header', + CONTENT: '.popover-body' + }; + var Event = { + HIDE: "hide" + EVENT_KEY, + HIDDEN: "hidden" + EVENT_KEY, + SHOW: "show" + EVENT_KEY, + SHOWN: "shown" + EVENT_KEY, + INSERTED: "inserted" + EVENT_KEY, + CLICK: "click" + EVENT_KEY, + FOCUSIN: "focusin" + EVENT_KEY, + FOCUSOUT: "focusout" + EVENT_KEY, + MOUSEENTER: "mouseenter" + EVENT_KEY, + MOUSELEAVE: "mouseleave" + EVENT_KEY + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var Popover = + /*#__PURE__*/ + function (_Tooltip) { + _inheritsLoose(Popover, _Tooltip); + + function Popover() { + return _Tooltip.apply(this, arguments) || this; + } + + var _proto = Popover.prototype; + + // Overrides + _proto.isWithContent = function isWithContent() { + return this.getTitle() || this._getContent(); + }; + + _proto.addAttachmentClass = function addAttachmentClass(attachment) { + $$$1(this.getTipElement()).addClass(CLASS_PREFIX + "-" + attachment); + }; + + _proto.getTipElement = function getTipElement() { + this.tip = this.tip || $$$1(this.config.template)[0]; + return this.tip; + }; + + _proto.setContent = function setContent() { + var $tip = $$$1(this.getTipElement()); // We use append for html objects to maintain js events + + this.setElementContent($tip.find(Selector.TITLE), this.getTitle()); + + var content = this._getContent(); + + if (typeof content === 'function') { + content = content.call(this.element); + } + + this.setElementContent($tip.find(Selector.CONTENT), content); + $tip.removeClass(ClassName.FADE + " " + ClassName.SHOW); + }; // Private + + + _proto._getContent = function _getContent() { + return this.element.getAttribute('data-content') || this.config.content; + }; + + _proto._cleanTipClass = function _cleanTipClass() { + var $tip = $$$1(this.getTipElement()); + var tabClass = $tip.attr('class').match(BSCLS_PREFIX_REGEX); + + if (tabClass !== null && tabClass.length > 0) { + $tip.removeClass(tabClass.join('')); + } + }; // Static + + + Popover._jQueryInterface = function _jQueryInterface(config) { + return this.each(function () { + var data = $$$1(this).data(DATA_KEY); + + var _config = typeof config === 'object' ? config : null; + + if (!data && /destroy|hide/.test(config)) { + return; + } + + if (!data) { + data = new Popover(this, _config); + $$$1(this).data(DATA_KEY, data); + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError("No method named \"" + config + "\""); + } + + data[config](); + } + }); + }; + + _createClass(Popover, null, [{ + key: "VERSION", + // Getters + get: function get() { + return VERSION; + } + }, { + key: "Default", + get: function get() { + return Default; + } + }, { + key: "NAME", + get: function get() { + return NAME; + } + }, { + key: "DATA_KEY", + get: function get() { + return DATA_KEY; + } + }, { + key: "Event", + get: function get() { + return Event; + } + }, { + key: "EVENT_KEY", + get: function get() { + return EVENT_KEY; + } + }, { + key: "DefaultType", + get: function get() { + return DefaultType; + } + }]); + + return Popover; + }(Tooltip); + /** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + */ + + + $$$1.fn[NAME] = Popover._jQueryInterface; + $$$1.fn[NAME].Constructor = Popover; + + $$$1.fn[NAME].noConflict = function () { + $$$1.fn[NAME] = JQUERY_NO_CONFLICT; + return Popover._jQueryInterface; + }; + + return Popover; + }($); + + /** + * -------------------------------------------------------------------------- + * Bootstrap (v4.1.3): scrollspy.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + + var ScrollSpy = function ($$$1) { + /** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + var NAME = 'scrollspy'; + var VERSION = '4.1.3'; + var DATA_KEY = 'bs.scrollspy'; + var EVENT_KEY = "." + DATA_KEY; + var DATA_API_KEY = '.data-api'; + var JQUERY_NO_CONFLICT = $$$1.fn[NAME]; + var Default = { + offset: 10, + method: 'auto', + target: '' + }; + var DefaultType = { + offset: 'number', + method: 'string', + target: '(string|element)' + }; + var Event = { + ACTIVATE: "activate" + EVENT_KEY, + SCROLL: "scroll" + EVENT_KEY, + LOAD_DATA_API: "load" + EVENT_KEY + DATA_API_KEY + }; + var ClassName = { + DROPDOWN_ITEM: 'dropdown-item', + DROPDOWN_MENU: 'dropdown-menu', + ACTIVE: 'active' + }; + var Selector = { + DATA_SPY: '[data-spy="scroll"]', + ACTIVE: '.active', + NAV_LIST_GROUP: '.nav, .list-group', + NAV_LINKS: '.nav-link', + NAV_ITEMS: '.nav-item', + LIST_ITEMS: '.list-group-item', + DROPDOWN: '.dropdown', + DROPDOWN_ITEMS: '.dropdown-item', + DROPDOWN_TOGGLE: '.dropdown-toggle' + }; + var OffsetMethod = { + OFFSET: 'offset', + POSITION: 'position' + /** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + + }; + + var ScrollSpy = + /*#__PURE__*/ + function () { + function ScrollSpy(element, config) { + var _this = this; + + this._element = element; + this._scrollElement = element.tagName === 'BODY' ? window : element; + this._config = this._getConfig(config); + this._selector = this._config.target + " " + Selector.NAV_LINKS + "," + (this._config.target + " " + Selector.LIST_ITEMS + ",") + (this._config.target + " " + Selector.DROPDOWN_ITEMS); + this._offsets = []; + this._targets = []; + this._activeTarget = null; + this._scrollHeight = 0; + $$$1(this._scrollElement).on(Event.SCROLL, function (event) { + return _this._process(event); + }); + this.refresh(); + + this._process(); + } // Getters + + + var _proto = ScrollSpy.prototype; + + // Public + _proto.refresh = function refresh() { + var _this2 = this; + + var autoMethod = this._scrollElement === this._scrollElement.window ? OffsetMethod.OFFSET : OffsetMethod.POSITION; + var offsetMethod = this._config.method === 'auto' ? autoMethod : this._config.method; + var offsetBase = offsetMethod === OffsetMethod.POSITION ? this._getScrollTop() : 0; + this._offsets = []; + this._targets = []; + this._scrollHeight = this._getScrollHeight(); + var targets = [].slice.call(document.querySelectorAll(this._selector)); + targets.map(function (element) { + var target; + var targetSelector = Util.getSelectorFromElement(element); + + if (targetSelector) { + target = document.querySelector(targetSelector); + } + + if (target) { + var targetBCR = target.getBoundingClientRect(); + + if (targetBCR.width || targetBCR.height) { + // TODO (fat): remove sketch reliance on jQuery position/offset + return [$$$1(target)[offsetMethod]().top + offsetBase, targetSelector]; + } + } + + return null; + }).filter(function (item) { + return item; + }).sort(function (a, b) { + return a[0] - b[0]; + }).forEach(function (item) { + _this2._offsets.push(item[0]); + + _this2._targets.push(item[1]); + }); + }; + + _proto.dispose = function dispose() { + $$$1.removeData(this._element, DATA_KEY); + $$$1(this._scrollElement).off(EVENT_KEY); + this._element = null; + this._scrollElement = null; + this._config = null; + this._selector = null; + this._offsets = null; + this._targets = null; + this._activeTarget = null; + this._scrollHeight = null; + }; // Private + + + _proto._getConfig = function _getConfig(config) { + config = _objectSpread({}, Default, typeof config === 'object' && config ? config : {}); + + if (typeof config.target !== 'string') { + var id = $$$1(config.target).attr('id'); + + if (!id) { + id = Util.getUID(NAME); + $$$1(config.target).attr('id', id); + } + + config.target = "#" + id; + } + + Util.typeCheckConfig(NAME, config, DefaultType); + return config; + }; + + _proto._getScrollTop = function _getScrollTop() { + return this._scrollElement === window ? this._scrollElement.pageYOffset : this._scrollElement.scrollTop; + }; + + _proto._getScrollHeight = function _getScrollHeight() { + return this._scrollElement.scrollHeight || Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); + }; + + _proto._getOffsetHeight = function _getOffsetHeight() { + return this._scrollElement === window ? window.innerHeight : this._scrollElement.getBoundingClientRect().height; + }; + + _proto._process = function _process() { + var scrollTop = this._getScrollTop() + this._config.offset; + + var scrollHeight = this._getScrollHeight(); + + var maxScroll = this._config.offset + scrollHeight - this._getOffsetHeight(); + + if (this._scrollHeight !== scrollHeight) { + this.refresh(); + } + + if (scrollTop >= maxScroll) { + var target = this._targets[this._targets.length - 1]; + + if (this._activeTarget !== target) { + this._activate(target); + } + + return; + } + + if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) { + this._activeTarget = null; + + this._clear(); + + return; + } + + var offsetLength = this._offsets.length; + + for (var i = offsetLength; i--;) { + var isActiveTarget = this._activeTarget !== this._targets[i] && scrollTop >= this._offsets[i] && (typeof this._offsets[i + 1] === 'undefined' || scrollTop < this._offsets[i + 1]); + + if (isActiveTarget) { + this._activate(this._targets[i]); + } + } + }; + + _proto._activate = function _activate(target) { + this._activeTarget = target; + + this._clear(); + + var queries = this._selector.split(','); // eslint-disable-next-line arrow-body-style + + + queries = queries.map(function (selector) { + return selector + "[data-target=\"" + target + "\"]," + (selector + "[href=\"" + target + "\"]"); + }); + var $link = $$$1([].slice.call(document.querySelectorAll(queries.join(',')))); + + if ($link.hasClass(ClassName.DROPDOWN_ITEM)) { + $link.closest(Selector.DROPDOWN).find(Selector.DROPDOWN_TOGGLE).addClass(ClassName.ACTIVE); + $link.addClass(ClassName.ACTIVE); + } else { + // Set triggered link as active + $link.addClass(ClassName.ACTIVE); // Set triggered links parents as active + // With both
A calendar event will be created for end users if one of their hosts fail any of these policies.{" "} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx new file mode 100644 index 0000000000..7c29b4979f --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useState } from "react"; + +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import { IPolicyStats } from "interfaces/policy"; +import softwareAPI, { + ISoftwareTitlesQueryKey, + ISoftwareTitlesResponse, +} from "services/entities/software"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Modal from "components/Modal"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; +import CustomLink from "components/CustomLink"; +import Button from "components/buttons/Button"; +import { ISoftwareTitle } from "interfaces/software"; + +const getPlatformDisplayFromPackageSuffix = (packageName: string) => { + const split = packageName.split("."); + const suff = split[split.length - 1]; + switch (suff) { + case "pkg": + return "macOS"; + case "deb": + return "Linux"; + case "exe": + return "Windows"; + case "msi": + return "Windows"; + default: + return null; + } +}; + +const AFI_SOFTWARE_BATCH_SIZE = 1000; + +const baseClass = "install-software-modal"; + +interface ISwDropdownField { + name: string; + value: number; +} +interface IFormPolicy { + name: string; + id: number; + installSoftwareEnabled: boolean; + swIdToInstall?: number; +} + +export type IInstallSoftwareFormData = IFormPolicy[]; + +interface IInstallSoftwareModal { + onExit: () => void; + onSubmit: (formData: IInstallSoftwareFormData) => void; + isUpdating: boolean; + policies: IPolicyStats[]; + teamId: number; +} +const InstallSoftwareModal = ({ + onExit, + onSubmit, + isUpdating, + policies, + teamId, +}: IInstallSoftwareModal) => { + const [formData, setFormData] = useState( + policies.map((policy) => ({ + name: policy.name, + id: policy.id, + installSoftwareEnabled: !!policy.install_software, + swIdToInstall: policy.install_software?.software_title_id, + })) + ); + + const anyPolicyEnabledWithoutSelectedSoftware = formData.some( + (policy) => policy.installSoftwareEnabled && !policy.swIdToInstall + ); + const { + data: titlesAFI, + isLoading: isTitlesAFILoading, + isError: isTitlesAFIError, + } = useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitle[], + [ISoftwareTitlesQueryKey] + >( + [ + { + scope: "software-titles", + page: 0, + perPage: AFI_SOFTWARE_BATCH_SIZE, + query: "", + orderDirection: "desc", + orderKey: "hosts_count", + teamId, + availableForInstall: true, + packagesOnly: true, + }, + ], + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), + { + select: (data) => data.software_titles, + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + + const onUpdateInstallSoftware = useCallback(() => { + onSubmit(formData); + }, [formData, onSubmit]); + + const onChangeEnableInstallSoftware = useCallback( + (newVal: { policyName: string; value: boolean }) => { + const { policyName, value } = newVal; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { + ...policy, + installSoftwareEnabled: value, + swIdToInstall: value ? policy.swIdToInstall : undefined, + }; + } + return policy; + }) + ); + }, + [formData] + ); + + const onSelectPolicySoftware = useCallback( + ({ name, value }: ISwDropdownField) => { + const [policyName, softwareId] = [name, value]; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { ...policy, swIdToInstall: softwareId }; + } + return policy; + }) + ); + }, + [formData] + ); + + const availableSoftwareOptions = titlesAFI?.map((title) => { + const platformDisplay = getPlatformDisplayFromPackageSuffix( + title.software_package?.name ?? "" + ); + const platformString = platformDisplay ? `${platformDisplay} • ` : ""; + return { + label: title.name, + value: title.id, + helpText: `${platformString}${title.software_package?.version ?? ""}`, + }; + }); + + const renderPolicySwInstallOption = (policy: IFormPolicy) => { + const { + name: policyName, + id: policyId, + installSoftwareEnabled: enabled, + swIdToInstall, + } = policy; + + return ( +
  • + { + onChangeEnableInstallSoftware({ + policyName, + value: !enabled, + }); + }} + > + + + {enabled && ( + + )} +
  • + ); + }; + + const renderContent = () => { + if (isTitlesAFIError) { + return ; + } + if (isTitlesAFILoading) { + return ; + } + if (!titlesAFI?.length) { + return ( +
    + No software available for install + + Go to Software to add software to this team. + +
    + ); + } + + return ( +
    +
    +
    Policies:
    +
      + {formData.map((policyData) => + renderPolicySwInstallOption(policyData) + )} +
    + + Selected software will be installed when hosts fail the chosen + policy.{" "} + + +
    +
    + + +
    +
    + ); + }; + + return ( + + {renderContent()} + + ); +}; + +export default InstallSoftwareModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss new file mode 100644 index 0000000000..de9cfc05be --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss @@ -0,0 +1,41 @@ +.manage-policies-page { + .install-software-modal { + .form-field--dropdown { + width: 276px; + .Select-placeholder { + color: $ui-fleet-black-50; + } + .Select-menu { + max-height: none; + overflow: visible; + } + .Select-menu-outer { + max-height: 240px; + overflow-y: auto; + } + } + .policy-row { + height: 40px; + padding-top: 4px; + padding-bottom: 4px; + } + + &__no-software { + display: flex; + height: 178px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + font-size: $small; + + span { + color: $ui-fleet-black-75; + font-size: $xx-small; + } + } + .data-error { + padding: 78px; + } + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts new file mode 100644 index 0000000000..a9f46a726a --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./InstallSoftwareModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index 75c66b5fd7..7a9668e825 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -367,6 +367,7 @@ const OtherWorkflowsModal = ({ title="Other workflows" className={baseClass} width="large" + isContentDisabled={isUpdating} >
    )} - {viewingTeamPolicies && !cellProps.row.original.team_id && ( - - )} + {viewingTeamPolicies && + cellProps.row.original.team_id === null && ( + + )} } path={PATHS.EDIT_POLICY(cellProps.row.original)} @@ -274,7 +275,7 @@ const generateTableHeaders = ( tableHeaders.unshift({ id: "selection", Header: (headerProps: any) => { - // When viewing team policies select all checkbox accounts for not selecting inherited policies + // When viewing team policies, the select all checkbox will ignore inherited policies const teamCheckboxProps = getConditionalSelectHeaderCheckboxProps({ headerProps, checkIfRowIsSelectable: (row) => row.original.team_id !== null, @@ -301,7 +302,7 @@ const generateTableHeaders = ( return ; }, Cell: (cellProps: ICellProps): JSX.Element => { - const inheritedPolicy = !cellProps.row.original.team_id; + const inheritedPolicy = cellProps.row.original.team_id === null; const props = cellProps.row.getToggleRowSelectedProps(); const checkboxProps = { value: props.checked, diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index 4fcb582bc4..48a39cb2c5 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -14,7 +14,11 @@ import { IStoredPolicyResponse, } from "interfaces/policy"; import { ITarget } from "interfaces/target"; -import { ITeam } from "interfaces/team"; +import { + API_ALL_TEAMS_ID, + APP_CONTEXT_ALL_TEAMS_ID, + ITeam, +} from "interfaces/team"; import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; import hostAPI from "services/entities/hosts"; @@ -84,7 +88,7 @@ const PolicyPage = ({ location, router, includeAllTeams: true, - includeNoTeam: false, + includeNoTeam: true, permittedAccessByTeamRole: { admin: true, maintainer: true, @@ -112,7 +116,11 @@ const PolicyPage = ({ return; } if (policyTeamId !== teamIdForApi) { - setPolicyTeamId(teamIdForApi || 0); + setPolicyTeamId( + teamIdForApi === API_ALL_TEAMS_ID + ? APP_CONTEXT_ALL_TEAMS_ID + : teamIdForApi + ); } }, [isRouteOk, teamIdForApi, policyTeamId, setPolicyTeamId]); @@ -155,6 +163,8 @@ const PolicyPage = ({ retry: false, select: (data: IStoredPolicyResponse) => data.policy, onSuccess: (returnedQuery) => { + const deNulledReturnedQueryTeamId = returnedQuery.team_id ?? undefined; + setLastEditedQueryId(returnedQuery.id); setLastEditedQueryName(returnedQuery.name); setLastEditedQueryDescription(returnedQuery.description); @@ -164,7 +174,11 @@ const PolicyPage = ({ setLastEditedQueryPlatform(returnedQuery.platform); // TODO(sarah): What happens if the team id in the policy response doesn't match the // url param? In theory, the backend should ensure this doesn't happen. - setPolicyTeamId(returnedQuery.team_id || 0); + setPolicyTeamId( + deNulledReturnedQueryTeamId === API_ALL_TEAMS_ID + ? APP_CONTEXT_ALL_TEAMS_ID + : deNulledReturnedQueryTeamId + ); }, onError: (error) => handlePageError(error), } @@ -197,7 +211,7 @@ const PolicyPage = ({ if ( !isOnGlobalTeam && !isStoredPolicyLoading && - storedPolicy?.team_id && + storedPolicy?.team_id !== undefined && !(storedPolicy?.team_id?.toString() === location.query.team_id) ) { router.push( @@ -205,9 +219,10 @@ const PolicyPage = ({ ); } + // this function is passed way down, wrapped and ultimately called by SaveNewPolicyModal const { mutateAsync: createPolicy } = useMutation( (formData: IPolicyFormData) => { - return formData.team_id + return formData.team_id !== undefined ? teamPoliciesAPI.create(formData) : globalPoliciesAPI.create(formData); } diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index c9d1266c20..8490d2c24a 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -98,7 +98,6 @@ const PolicyForm = ({ // Note: The PolicyContext values should always be used for any mutable policy data such as query name // The storedPolicy prop should only be used to access immutable metadata such as author id const { - policyTeamId, lastEditedQueryId, lastEditedQueryName, lastEditedQueryDescription, diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index 4f51ef87ef..553209818e 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -14,6 +14,7 @@ import { IPolicyFormData, IPolicy } from "interfaces/policy"; import BackLink from "components/BackLink"; import PolicyForm from "pages/policies/PolicyPage/components/PolicyForm"; +import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team"; interface IQueryEditorProps { router: InjectedRouter; @@ -23,8 +24,6 @@ interface IQueryEditorProps { storedPolicyError: Error | null; showOpenSchemaActionText: boolean; isStoredPolicyLoading: boolean; - isTeamAdmin: boolean; - isTeamMaintainer: boolean; isTeamObserver: boolean; createPolicy: (formData: IPolicyFormData) => Promise; onOsqueryTableSelect: (tableName: string) => void; @@ -41,8 +40,6 @@ const QueryEditor = ({ storedPolicyError, showOpenSchemaActionText, isStoredPolicyLoading, - isTeamAdmin, - isTeamMaintainer, isTeamObserver, createPolicy, onOsqueryTableSelect, @@ -86,7 +83,6 @@ const QueryEditor = ({ policyAutofillData, setPolicyAutofillData, ] = useState(null); - const [policyAutofillErrors, setPolicyAutofillErrors] = useState({}); const [ isFetchingAutofillDescription, setIsFetchingAutofillDescription, @@ -115,7 +111,6 @@ const QueryEditor = ({ } catch (error) { console.log(error); renderFlash("error", "Couldn't autofill policy data."); - setPolicyAutofillErrors(error); } setIsFetchingAutofillDescription(false); } @@ -139,14 +134,13 @@ const QueryEditor = ({ } catch (error) { console.log(error); renderFlash("error", "Couldn't autofill policy data."); - setPolicyAutofillErrors(error); } setIsFetchingAutofillResolution(false); } }; const onCreatePolicy = debounce(async (formData: IPolicyFormData) => { - if (policyTeamId) { + if (policyTeamId !== APP_CONTEXT_ALL_TEAMS_ID) { formData.team_id = policyTeamId; } setIsUpdatingPolicy(true); @@ -204,9 +198,9 @@ const QueryEditor = ({ const updateAPIRequest = () => { // storedPolicy.team_id is used for existing policies because selectedTeamId is subject to change - const team_id = storedPolicy?.team_id; + const team_id = storedPolicy?.team_id ?? undefined; - return team_id + return team_id !== undefined ? teamPoliciesAPI.update(policyIdForEdit, { ...updatedPolicy, team_id, diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx index 9037d20a89..eae763105b 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTable.tsx @@ -272,7 +272,7 @@ const QueriesTable = ({ options={PLATFORM_FILTER_OPTIONS} searchable={false} onChange={handlePlatformFilterDropdownChange} - tableFilterDropdown + iconName="filter" /> ); }, [platform, queryParams, router]); diff --git a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx index 42a2413c99..cdaec1aebd 100644 --- a/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx +++ b/frontend/pages/queries/details/QueryDetailsPage/QueryDetailsPage.tsx @@ -49,7 +49,12 @@ interface IQueryDetailsPageProps { params: Params; location: { pathname: string; - query: { team_id?: string; order_key?: string; order_direction?: string }; + query: { + team_id?: string; + order_key?: string; + order_direction?: string; + host_id?: string; + }; search: string; }; } @@ -67,6 +72,12 @@ const QueryDetailsPage = ({ } const queryParams = location.query; + // Present when observer is redirected from host details > query + // since observer does not have access to edit page + const hostId = queryParams?.host_id + ? parseInt(queryParams.host_id, 10) + : undefined; + const { currentTeamId } = useTeamIdParam({ location, router, @@ -295,7 +306,7 @@ const QueryDetailsPage = ({ onClick={() => { queryId && router.push( - PATHS.LIVE_QUERY(queryId, currentTeamId) + PATHS.LIVE_QUERY(queryId, currentTeamId, hostId) ); }} disabled={isLiveQueryDisabled} diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx index addfd6b061..d2bc087b60 100644 --- a/frontend/pages/queries/edit/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -208,7 +208,14 @@ const EditQueryPage = ({ queryId > 0 && !canEditExistingQuery ) { - router.push(PATHS.QUERY_DETAILS(queryId)); + // Reroute to query report page still maintains query params for live query purposes + const baseUrl = PATHS.QUERY_DETAILS(queryId); + const queryParams = buildQueryStringFromParams({ + host_id: location.query.host_id, + team_id: location.query.team_id, + }); + + router.push(queryParams ? `${baseUrl}?${queryParams}` : baseUrl); } }, [queryId, isTeamMaintainerOrTeamAdmin, isStoredQueryLoading]); diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index f3785ba771..ecdb4aa9fc 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -64,9 +64,9 @@ import SetupExperience from "pages/ManageControlsPage/SetupExperience/SetupExper import WindowsMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage"; import AppleMdmPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage"; import Scripts from "pages/ManageControlsPage/Scripts/Scripts"; -import AppleAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/AppleAutomaticEnrollmentPage"; -import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/AutomaticEnrollment/WindowsAutomaticEnrollmentPage"; -import VppSetupPage from "pages/admin/IntegrationsPage/cards/Vpp/VppSetupPage"; +import WindowsAutomaticEnrollmentPage from "pages/admin/IntegrationsPage/cards/MdmSettings/WindowsAutomaticEnrollmentPage"; +import AppleBusinessManagerPage from "pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage"; +import VppPage from "pages/admin/IntegrationsPage/cards/MdmSettings/VppPage"; import HostQueryReport from "pages/hosts/details/HostQueryReport"; import SoftwarePage from "pages/SoftwarePage"; import SoftwareTitles from "pages/SoftwarePage/SoftwareTitles"; @@ -168,6 +168,13 @@ const routes = ( component={OrgSettingsPage} /> + {/* This redirect is used to handle the old URL for these two + pages */} + + + {/* This redirect is used to handle old apple automatic enrollments page */} + - + {/* This redirect is used to handle old vpp setup page */} + + + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 1ca4c6e39d..1db857b432 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -1,3 +1,5 @@ +import { buildQueryStringFromParams } from "utilities/url"; + import { IPolicy } from "../interfaces/policy"; import URL_PREFIX from "./url_prefix"; @@ -26,20 +28,25 @@ export default { DASHBOARD_IOS: `${URL_PREFIX}/dashboard/ios`, DASHBOARD_IPADOS: `${URL_PREFIX}/dashboard/ipados`, - // Admin pages + /** + * Admin pages + */ + ADMIN_SETTINGS: `${URL_PREFIX}/settings`, ADMIN_USERS: `${URL_PREFIX}/settings/users`, + + // Integrations pages ADMIN_INTEGRATIONS: `${URL_PREFIX}/settings/integrations`, ADMIN_INTEGRATIONS_TICKET_DESTINATIONS: `${URL_PREFIX}/settings/integrations/ticket-destinations`, ADMIN_INTEGRATIONS_MDM: `${URL_PREFIX}/settings/integrations/mdm`, ADMIN_INTEGRATIONS_MDM_APPLE: `${URL_PREFIX}/settings/integrations/mdm/apple`, ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`, - ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`, - ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_APPLE: `${URL_PREFIX}/settings/integrations/automatic-enrollment/apple`, + ADMIN_INTEGRATIONS_APPLE_BUSINESS_MANAGER: `${URL_PREFIX}/settings/integrations/mdm/abm`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`, ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`, - ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/vpp`, + ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/mdm/vpp`, ADMIN_INTEGRATIONS_VPP_SETUP: `${URL_PREFIX}/settings/integrations/vpp/setup`, + ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`, ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`, @@ -90,10 +97,17 @@ export default { teamId ? `?team_id=${teamId}` : "" }`; }, - LIVE_QUERY: (queryId: number | null, teamId?: number): string => { - return `${URL_PREFIX}/queries/${queryId || "new"}/live${ - teamId ? `?team_id=${teamId}` : "" - }`; + LIVE_QUERY: ( + queryId: number | null, + teamId?: number, + hostId?: number + ): string => { + const baseUrl = `${URL_PREFIX}/queries/${queryId || "new"}/live`; + const queryParams = buildQueryStringFromParams({ + team_id: teamId, + host_id: hostId, + }); + return queryParams ? `${baseUrl}?${queryParams}` : baseUrl; }, QUERY_DETAILS: (queryId: number, teamId?: number): string => { return `${URL_PREFIX}/queries/${queryId}${ @@ -102,7 +116,7 @@ export default { }, EDIT_POLICY: (policy: IPolicy): string => { return `${URL_PREFIX}/policies/${policy.id}${ - policy.team_id ? `?team_id=${policy.team_id}` : "" + policy.team_id !== undefined ? `?team_id=${policy.team_id}` : "" }`; }, FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`, diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index ba7e0dc7ab..5a5c625f0c 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -12,7 +12,7 @@ import { import { IHostSoftware, ISoftware, - SoftwareInstallStatus, + SoftwareAggregateStatus, } from "interfaces/software"; import { DiskEncryptionStatus, @@ -72,7 +72,7 @@ export interface ILoadHostsOptions { softwareId?: number; softwareTitleId?: number; softwareVersionId?: number; - softwareStatus?: SoftwareInstallStatus; + softwareStatus?: SoftwareAggregateStatus; status?: HostStatus; mdmId?: number; mdmEnrollmentStatus?: string; @@ -103,7 +103,7 @@ export interface IExportHostsOptions { softwareId?: number; softwareTitleId?: number; softwareVersionId?: number; - softwareStatus?: SoftwareInstallStatus; + softwareStatus?: SoftwareAggregateStatus; status?: HostStatus; mdmId?: number; munkiIssueId?: number; @@ -133,7 +133,7 @@ export interface IActionByFilter { softwareId?: number | null; softwareTitleId?: number | null; softwareVersionId?: number | null; - softwareStatus?: SoftwareInstallStatus; + softwareStatus?: SoftwareAggregateStatus; osName?: string; osVersion?: string; osVersionId?: number | null; @@ -590,4 +590,11 @@ export default { HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId) ); }, + uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => { + const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints; + return sendRequest( + "POST", + HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId) + ); + }, }; diff --git a/frontend/services/entities/labels.ts b/frontend/services/entities/labels.ts index 7a58cb9a04..5ce8d04938 100644 --- a/frontend/services/entities/labels.ts +++ b/frontend/services/entities/labels.ts @@ -49,6 +49,28 @@ const generateCreateLabelBody = ( const generateUpdateLabelBody = generateCreateLabelBody; +/** gets the custom label and returns them in case-insensitive alphabetical + * ascending order by label name. (e.g. [A, B, C, a, b, c] => [A, a, B, b, C, c]) + */ +export const getCustomLabels = ( + labels: T[] +) => { + if (labels.length === 0) { + return []; + } + + return labels + .filter((label) => label.label_type === "regular") + .sort((a, b) => { + // Found this technique here + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare + // This is a case insensitive sort + return a.name.localeCompare(b.name, undefined, { + sensitivity: "base", + }); + }); +}; + export default { create: ( formData: IDynamicLabelFormData | IManualLabelFormData diff --git a/frontend/services/entities/mdm_apple.ts b/frontend/services/entities/mdm_apple.ts index 34ea85747f..4a50779df4 100644 --- a/frontend/services/entities/mdm_apple.ts +++ b/frontend/services/entities/mdm_apple.ts @@ -1,3 +1,4 @@ +import { IMdmVppToken } from "interfaces/mdm"; import { ApplePlatform } from "interfaces/platform"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; @@ -18,10 +19,27 @@ export interface IVppApp { platform: ApplePlatform; } +interface IAddVppAppPostBody { + app_store_id: string; + team_id: number; + platform: ApplePlatform; + self_service?: boolean; +} + export interface IGetVppAppsResponse { app_store_apps: IVppApp[]; } +export interface IGetVppTokensResponse { + vpp_tokens: IMdmVppToken[]; +} + +export interface IUploadVppTokenReponse { + vpp_token: IMdmVppToken; +} + +export type IRenewVppTokenResponse = IUploadVppTokenReponse; + export default { getAppleAPNInfo: () => { const { MDM_APPLE_PNS } = endpoints; @@ -46,18 +64,6 @@ export default { return sendRequest("GET", MDM_REQUEST_CSR); }, - getVppInfo: (): Promise => { - const { MDM_APPLE_VPP } = endpoints; - return sendRequest("GET", MDM_APPLE_VPP); - }, - - uploadVppToken: (token: File) => { - const { MDM_APPLE_VPP_TOKEN } = endpoints; - const formData = new FormData(); - formData.append("token", token); - return sendRequest("POST", MDM_APPLE_VPP_TOKEN, formData); - }, - disableVpp: () => { const { MDM_APPLE_VPP_TOKEN } = endpoints; return sendRequest("DELETE", MDM_APPLE_VPP_TOKEN); @@ -69,12 +75,58 @@ export default { return sendRequest("GET", path); }, - addVppApp: (teamId: number, appStoreId: string, platform: ApplePlatform) => { + addVppApp: ( + teamId: number, + appStoreId: string, + platform: ApplePlatform, + isSelfService: boolean + ) => { const { MDM_APPLE_VPP_APPS } = endpoints; - return sendRequest("POST", MDM_APPLE_VPP_APPS, { + const postBody: IAddVppAppPostBody = { app_store_id: appStoreId, team_id: teamId, platform, - }); + }; + + if (isSelfService) { + postBody.self_service = isSelfService; + } + + return sendRequest("POST", MDM_APPLE_VPP_APPS, postBody); + }, + + getVppTokens: (): Promise => { + const { MDM_VPP_TOKENS } = endpoints; + return sendRequest("GET", MDM_VPP_TOKENS); + }, + + uploadVppToken: (token: File): Promise => { + const { MDM_VPP_TOKENS } = endpoints; + const formData = new FormData(); + formData.append("token", token); + return sendRequest("POST", MDM_VPP_TOKENS, formData); + }, + + renewVppToken(id: number, token: File): Promise { + const { MDM_VPP_TOKENS_RENEW } = endpoints; + const path = MDM_VPP_TOKENS_RENEW(id); + const formData = new FormData(); + formData.append("token", token); + return sendRequest("PATCH", path, formData); + }, + + deleteVppToken: (id: number): Promise => { + const { MDM_VPP_TOKEN } = endpoints; + const path = MDM_VPP_TOKEN(id); + return sendRequest("DELETE", path); + }, + + editVppTeams: async (params: { + tokenId: number; + teamIds: number[] | null; + }) => { + const { MDM_VPP_TOKEN_TEAMS } = endpoints; + const path = MDM_VPP_TOKEN_TEAMS(params.tokenId); + return sendRequest("PATCH", path, { teams: params.teamIds }); }, }; diff --git a/frontend/services/entities/mdm_apple_bm.ts b/frontend/services/entities/mdm_apple_bm.ts index b963bd1d64..f00ad36a4b 100644 --- a/frontend/services/entities/mdm_apple_bm.ts +++ b/frontend/services/entities/mdm_apple_bm.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { IMdmAbmToken } from "interfaces/mdm"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; @@ -14,6 +15,14 @@ export interface IGetAppleBMInfoResponse { renew_date: string; } +export interface IGetAbmTokensResponse { + abm_tokens: IMdmAbmToken[]; +} + +export interface IAbmTokenResponse { + abm_token: IMdmAbmToken; +} + export default { getAppleBMInfo: (): Promise => { const { MDM_APPLE_BM } = endpoints; @@ -46,16 +55,45 @@ export default { return sendRequest("GET", MDM_APPLE_ABM_PUBLIC_KEY); }, - uploadToken: (token: File) => { - const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints; + uploadToken: (token: File): Promise => { + const { MDM_ABM_TOKENS } = endpoints; const formData = new FormData(); formData.append("token", token); - return sendRequest("POST", MDM_APPLE_BM_TOKEN, formData); + return sendRequest("POST", MDM_ABM_TOKENS, formData); }, - disableAutomaticEnrollment: () => { - const { MDM_APPLE_ABM_TOKEN: MDM_APPLE_BM_TOKEN } = endpoints; - return sendRequest("DELETE", MDM_APPLE_BM_TOKEN); + renewToken: (id: number, token: File): Promise => { + const { MDM_ABM_TOKEN_RENEW } = endpoints; + const path = MDM_ABM_TOKEN_RENEW(id); + + const formData = new FormData(); + formData.append("token", token); + + return sendRequest("PATCH", path, formData); + }, + + deleteToken: (id: number): Promise => { + const { MDM_ABM_TOKEN } = endpoints; + const path = MDM_ABM_TOKEN(id); + return sendRequest("DELETE", path); + }, + + getTokens: (): Promise => { + const { MDM_ABM_TOKENS } = endpoints; + return sendRequest("GET", MDM_ABM_TOKENS); + }, + + editTeams: async (params: { + tokenId: number; + teams: { + ios_team_id: number; + ipados_team_id: number; + macos_team_id: number; + }; + }) => { + const { MDM_ABM_TOKEN_TEAMS } = endpoints; + const path = MDM_ABM_TOKEN_TEAMS(params.tokenId); + return sendRequest("PATCH", path, params.teams); }, }; diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 8ed35f9a17..6f5792cd25 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -39,6 +39,7 @@ export interface IScriptResultResponse { message: string; runtime: number; host_timeout: boolean; + created_at: string; } /** diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index ecb4d98847..b244f6de4d 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -1,5 +1,3 @@ -import { AxiosResponse } from "axios"; - import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { @@ -13,7 +11,7 @@ import { buildQueryStringFromParams, convertParamsToSnakeCase, } from "utilities/url"; -import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm"; +import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm"; export interface ISoftwareApiParams { page?: number; @@ -22,7 +20,11 @@ export interface ISoftwareApiParams { orderDirection?: "asc" | "desc"; query?: string; vulnerable?: boolean; + max_cvss_score?: number; + min_cvss_score?: number; + exploit?: boolean; availableForInstall?: boolean; + packagesOnly?: boolean; selfService?: boolean; teamId?: number; } @@ -56,10 +58,14 @@ export interface ISoftwareVersionResponse { } export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams { + // used to trigger software refetches from sibling pages + addedSoftwareToken: string | null; scope: "software-versions"; } export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams { + // used to trigger software refetches from sibling pages + addedSoftwareToken?: string | null; scope: "software-titles"; } @@ -92,6 +98,10 @@ export interface IGetSoftwareVersionQueryKey scope: "softwareVersion"; } +export interface ISoftwareInstallTokenResponse { + token: string; +} + const ORDER_KEY = "name"; const ORDER_DIRECTION = "asc"; @@ -195,7 +205,7 @@ export default { }, addSoftwarePackage: ( - data: IAddSoftwareFormData, + data: IPackageFormData, teamId?: number, timeout?: number ) => { @@ -209,8 +219,10 @@ export default { formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); - data.preInstallCondition && - formData.append("pre_install_query", data.preInstallCondition); + data.uninstallScript && + formData.append("uninstall_script", data.uninstallScript); + data.preInstallQuery && + formData.append("pre_install_query", data.preInstallQuery); data.postInstallScript && formData.append("post_install_script", data.postInstallScript); teamId && formData.append("team_id", teamId.toString()); @@ -224,7 +236,32 @@ export default { true ); }, + editSoftwarePackage: ( + data: IPackageFormData, + softwareId: number, + teamId: number, + timeout?: number + ) => { + const { EDIT_SOFTWARE_PACKAGE } = endpoints; + const formData = new FormData(); + formData.append("team_id", teamId.toString()); + data.software && formData.append("software", data.software); + formData.append("self_service", data.selfService.toString()); + formData.append("install_script", data.installScript); + formData.append("pre_install_query", data.preInstallQuery || ""); + formData.append("post_install_script", data.postInstallScript || ""); + formData.append("uninstall_script", data.uninstallScript || ""); + + return sendRequest( + "PATCH", + EDIT_SOFTWARE_PACKAGE(softwareId), + formData, + undefined, + timeout, + true + ); + }, deleteSoftwarePackage: (softwareId: number, teamId: number) => { const { SOFTWARE_AVAILABLE_FOR_INSTALL } = endpoints; const path = `${SOFTWARE_AVAILABLE_FOR_INSTALL( @@ -233,23 +270,15 @@ export default { return sendRequest("DELETE", path); }, - downloadSoftwarePackage: ( + getSoftwarePackageToken: ( softwareTitleId: number, teamId: number - ): Promise => { - const path = `${endpoints.SOFTWARE_PACKAGE( + ): Promise => { + const path = `${endpoints.SOFTWARE_PACKAGE_TOKEN( softwareTitleId )}?${buildQueryStringFromParams({ alt: "media", team_id: teamId })}`; - return sendRequest( - "GET", - path, - undefined, - "blob", - undefined, - undefined, - true // return raw response - ); + return sendRequest("POST", path); }, getSoftwareInstallResult: (installUuid: string) => { diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index a10d954e8b..5da02ad3fc 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -86,6 +86,7 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -98,10 +99,11 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, }); }, destroy: (teamId: number | undefined, ids: number[]) => { - if (!teamId || teamId <= API_NO_TEAM_ID) { + if (teamId === undefined || teamId < API_NO_TEAM_ID) { return Promise.reject( new Error( `Invalid team id: ${teamId} must be greater than ${API_NO_TEAM_ID}` @@ -121,10 +123,6 @@ export default { loadAll: (team_id?: number): Promise => { const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies`; - if (!team_id) { - throw new Error("Invalid team id"); - } - return sendRequest("GET", path); }, loadAllNew: async ({ @@ -150,10 +148,6 @@ export default { const snakeCaseParams = convertParamsToSnakeCase(queryParams); const queryString = buildQueryStringFromParams(snakeCaseParams); const path = `${TEAMS}/${teamId}/policies?${queryString}`; - if (!teamId) { - throw new Error("Invalid team id"); - } - return sendRequest("GET", path); }, getCount: async ({ diff --git a/frontend/services/entities/vulnerabilities.ts b/frontend/services/entities/vulnerabilities.ts index fbad6c5f80..baadb559bd 100644 --- a/frontend/services/entities/vulnerabilities.ts +++ b/frontend/services/entities/vulnerabilities.ts @@ -85,6 +85,13 @@ const getVulnerability = ({ return sendRequest("GET", path); }; +export type IVulnerabilitiesEmptyStateReason = + | "unknown-cve" + | "invalid-cve" + | "known-vuln" + | "no-matching-items" + | "no-vulns-detected"; + export default { getVulnerabilities, getVulnerability, diff --git a/frontend/styles/byod.css b/frontend/styles/byod.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index df8714f0ce..31bb7259bd 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -132,6 +132,9 @@ $max-width: 2560px; font-size: $xx-small; font-weight: $regular; @include grey-text; + .custom-link { + font-size: inherit; + } } @mixin link { diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html new file mode 100644 index 0000000000..bb3853ecdf --- /dev/null +++ b/frontend/templates/enroll-ota.html @@ -0,0 +1,200 @@ + + + + + + + Fleet + + + +
    + +
    +
    +

    + Enroll your + iPhone or iPad to + Fleet +

    +

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

    +
      +
    1. +

      + 1. + + Download the Fleet profile and select Allow in the + pop-up. + +

      + Download +
    2. +
    3. +

      + 2. + + Navigate to Settings and select Profile Downloaded. + +

      +
      + select profile downloaded in settings +
      +
    4. +
    5. +

      + 3. + Select Install. +

      +
      + select install +
      +
    6. +
    +
    + + + diff --git a/frontend/test/test-utils.tsx b/frontend/test/test-utils.tsx index ce7bde4298..5e4e089889 100644 --- a/frontend/test/test-utils.tsx +++ b/frontend/test/test-utils.tsx @@ -171,3 +171,17 @@ export const createMockRouter = (overrides?: Partial) => { ...overrides, }; }; + +/** helper method to generate a date "x" days ago. */ +export const getPastDate = (days: number) => { + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() - days); + return targetDate.toISOString(); +}; + +/** helper method to generate a date "x" days from now */ +export const getFutureDate = (days: number) => { + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + days); + return targetDate.toISOString(); +}; diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index c8c9128317..4b780aebc9 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -60,9 +60,13 @@ export const HOST_STATUS_WEBHOOK_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [ export const GITHUB_NEW_ISSUE_LINK = "https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md"; -export const SUPPORT_LINK = "https://fleetdm.com/support"; +export const FLEET_WEBSITE_URL = "https://fleetdm.com"; -export const CONTACT_FLEET_LINK = "https://fleetdm.com/contact"; +export const SUPPORT_LINK = `${FLEET_WEBSITE_URL}/support`; + +export const CONTACT_FLEET_LINK = `${FLEET_WEBSITE_URL}/contact`; + +export const LEARN_MORE_ABOUT_BASE_LINK = `${FLEET_WEBSITE_URL}/learn-more-about`; /** July 28, 2016 is the date of the initial commit to fleet/fleet. */ export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z"; @@ -307,6 +311,9 @@ export const HOSTS_SEARCH_BOX_PLACEHOLDER = export const HOSTS_SEARCH_BOX_TOOLTIP = "Search hosts by name, hostname, UUID, serial number, or private IP address"; +export const VULNERABILITIES_SEARCH_BOX_TOOLTIP = + 'To search for an exact CVE, surround the string in double quotes (e.g. "CVE-2024-1234")'; + // Keys from API export const MDM_STATUS_TOOLTIP: Record = { "On (automatic)": ( diff --git a/frontend/utilities/date_format/date_format.tests.ts b/frontend/utilities/date_format/date_format.tests.ts index 102f34d048..b39d0d1eeb 100644 --- a/frontend/utilities/date_format/date_format.tests.ts +++ b/frontend/utilities/date_format/date_format.tests.ts @@ -1,6 +1,6 @@ -import { uploadedFromNow } from "."; +import { monthDayYearFormat, uploadedFromNow } from "."; -describe("date_format", () => { +describe("date_format utilities", () => { describe("uploadedFromNow util", () => { it("returns an user friendly uploaded at message", () => { const currentDate = new Date(); @@ -10,4 +10,11 @@ describe("date_format", () => { expect(uploadedFromNow(twoDaysAgo)).toEqual("Uploaded 2 days ago"); }); }); + + describe("monthDayYearFormat util", () => { + it("returns a date in the format of 'MonthName Date, Year' (e.g. January 01, 2024)", () => { + const date = "2024-11-29T00:00:00Z"; + expect(monthDayYearFormat(date)).toEqual("November 29, 2024"); + }); + }); }); diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts index 329d977dba..538c3a827d 100644 --- a/frontend/utilities/date_format/index.ts +++ b/frontend/utilities/date_format/index.ts @@ -1,4 +1,4 @@ -import { formatDistanceToNow } from "date-fns"; +import { format, formatDistanceToNow, intlFormat } from "date-fns"; /** Utility to create a string from a date in this format: `Uploaded .... ago` @@ -15,3 +15,11 @@ export const dateAgo = (date: string) => { // NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior. return `${formatDistanceToNow(new Date(date))} ago`; }; + +/** + * returns a date in the format of 'MonthName Date, Year' + * @example "January 01, 2024" + */ +export const monthDayYearFormat = (date: string) => { + return format(date, "MMMM d, yyyy"); +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 92553ada42..29524b4c31 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -52,7 +52,9 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`, HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`, HOST_SOFTWARE_PACKAGE_INSTALL: (hostId: number, softwareId: number) => - `/${API_VERSION}/fleet/hosts/${hostId}/software/install/${softwareId}`, + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`, + HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) => + `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`, INVITES: `/${API_VERSION}/fleet/invites`, @@ -71,21 +73,38 @@ export default { LOGOUT: `/${API_VERSION}/fleet/logout`, MACADMINS: `/${API_VERSION}/fleet/macadmins`, - // MDM endpoints + /** + * MDM endpoints + */ + + MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, + + // apple mdm endpoints MDM_APPLE: `/${API_VERSION}/fleet/mdm/apple`, - MDM_APPLE_ABM_TOKEN: `/${API_VERSION}/fleet/mdm/apple/abm_token`, + + // Apple Business Manager (ABM) endpoints + MDM_ABM_TOKENS: `/${API_VERSION}/fleet/abm_tokens`, + MDM_ABM_TOKEN: (id: number) => `/${API_VERSION}/fleet/abm_tokens/${id}`, + MDM_ABM_TOKEN_RENEW: (id: number) => + `/${API_VERSION}/fleet/abm_tokens/${id}/renew`, + MDM_ABM_TOKEN_TEAMS: (id: number) => + `/${API_VERSION}/fleet/abm_tokens/${id}/teams`, MDM_APPLE_ABM_PUBLIC_KEY: `/${API_VERSION}/fleet/mdm/apple/abm_public_key`, MDM_APPLE_APNS_CERTIFICATE: `/${API_VERSION}/fleet/mdm/apple/apns_certificate`, MDM_APPLE_PNS: `/${API_VERSION}/fleet/apns`, - MDM_APPLE_BM: `/${API_VERSION}/fleet/abm`, + MDM_APPLE_BM: `/${API_VERSION}/fleet/abm`, // TODO: Deprecated? MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple/dep/key_pair`, MDM_APPLE_VPP_APPS: `/${API_VERSION}/fleet/software/app_store_apps`, - MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, MDM_REQUEST_CSR: `/${API_VERSION}/fleet/mdm/apple/request_csr`, // Apple VPP endpoints - MDM_APPLE_VPP: `/${API_VERSION}/fleet/vpp`, - MDM_APPLE_VPP_TOKEN: `/${API_VERSION}/fleet/mdm/apple/vpp_token`, + MDM_APPLE_VPP_TOKEN: `/${API_VERSION}/fleet/mdm/apple/vpp_token`, // TODO: Deprecated? + MDM_VPP_TOKENS: `/${API_VERSION}/fleet/vpp_tokens`, + MDM_VPP_TOKEN: (id: number) => `/${API_VERSION}/fleet/vpp_tokens/${id}`, + MDM_VPP_TOKENS_RENEW: (id: number) => + `/${API_VERSION}/fleet/vpp_tokens/${id}/renew`, + MDM_VPP_TOKEN_TEAMS: (id: number) => + `/${API_VERSION}/fleet/vpp_tokens/${id}/teams`, // MDM profile endpoints MDM_PROFILES: `/${API_VERSION}/fleet/mdm/profiles`, @@ -141,14 +160,16 @@ export default { SOFTWARE: `/${API_VERSION}/fleet/software`, SOFTWARE_TITLES: `/${API_VERSION}/fleet/software/titles`, SOFTWARE_TITLE: (id: number) => `/${API_VERSION}/fleet/software/titles/${id}`, + EDIT_SOFTWARE_PACKAGE: (id: number) => + `/${API_VERSION}/fleet/software/titles/${id}/package`, SOFTWARE_VERSIONS: `/${API_VERSION}/fleet/software/versions`, SOFTWARE_VERSION: (id: number) => `/${API_VERSION}/fleet/software/versions/${id}`, SOFTWARE_PACKAGE_ADD: `/${API_VERSION}/fleet/software/package`, - SOFTWARE_PACKAGE: (id: number) => - `/${API_VERSION}/fleet/software/${id}/package`, + SOFTWARE_PACKAGE_TOKEN: (id: number) => + `/${API_VERSION}/fleet/software/titles/${id}/package/token`, SOFTWARE_INSTALL_RESULTS: (uuid: string) => - `/${API_VERSION}/fleet/software/install/results/${uuid}`, + `/${API_VERSION}/fleet/software/install/${uuid}/results`, SOFTWARE_PACKAGE_INSTALL: (id: number) => `/${API_VERSION}/fleet/software/packages/${id}`, SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) => diff --git a/frontend/utilities/helpers.tests.tsx b/frontend/utilities/helpers.tests.tsx index 3efbeb3a3b..f7acfa7944 100644 --- a/frontend/utilities/helpers.tests.tsx +++ b/frontend/utilities/helpers.tests.tsx @@ -1,4 +1,9 @@ -import { removeOSPrefix, compareVersions } from "./helpers"; +import { getPastDate, getFutureDate } from "test/test-utils"; +import { + removeOSPrefix, + compareVersions, + willExpireWithinXDays, +} from "./helpers"; describe("helpers utilities", () => { describe("removeOSPrefix function", () => { @@ -46,4 +51,30 @@ describe("helpers utilities", () => { expect(compareVersions("14", "14.0.0")).toEqual(0); }); }); + + describe("willExpireWithinXDays function", () => { + it("will return true if the date is within x number of days", () => { + const fiveDaysFromNow = getFutureDate(5); + expect(willExpireWithinXDays(fiveDaysFromNow, 10)).toEqual(true); + + const tenDaysFromNow = getFutureDate(10); + expect(willExpireWithinXDays(tenDaysFromNow, 30)).toEqual(true); + }); + + it("will return false if the date is not within x number of days", () => { + const thirtyDaysFromNow = getFutureDate(30); + expect(willExpireWithinXDays(thirtyDaysFromNow, 10)).toEqual(false); + + const fiftyDaysFromNow = getFutureDate(50); + expect(willExpireWithinXDays(fiftyDaysFromNow, 30)).toEqual(false); + }); + + it("will return false if the date has already expired", () => { + const fiveDaysAgo = getPastDate(5); + expect(willExpireWithinXDays(fiveDaysAgo, 10)).toEqual(false); + + const fiftyDaysAgo = getPastDate(50); + expect(willExpireWithinXDays(fiftyDaysAgo, 30)).toEqual(false); + }); + }); }); diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index efd67bb371..49f0022383 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -11,6 +11,7 @@ import { trim, trimEnd, union, + uniqueId, } from "lodash"; import md5 from "js-md5"; import { @@ -722,13 +723,17 @@ export const hasLicenseExpired = (expiration: string): boolean => { return isAfter(new Date(), new Date(expiration)); }; -export const willExpireWithinXDays = ( - expiration: string, - x: number -): boolean => { +/** + * determines if a date will expire within "x" number of days. If the date has + * has already expired, this function will return false. + */ +export const willExpireWithinXDays = (expiration: string, x: number) => { const xDaysFromNow = addDays(new Date(), x); - return isAfter(xDaysFromNow, new Date(expiration)); + return ( + !hasLicenseExpired(expiration) && + isAfter(xDaysFromNow, new Date(expiration)) + ); }; export const readableDate = (date: string) => { @@ -821,6 +826,17 @@ export const syntaxHighlight = (json: any): string => { /* eslint-enable no-useless-escape */ }; +export const tooltipTextWithLineBreaks = (lines: string[]) => { + return lines.map((line) => { + return ( + + {line} +
    +
    + ); + }); +}; + export const getSortedTeamOptions = memoize((teams: ITeam[]) => teams .map((team) => { diff --git a/frontend/utilities/software_install_scripts.ts b/frontend/utilities/software_install_scripts.ts index 5bc59c6763..5c21e8a1f4 100644 --- a/frontend/utilities/software_install_scripts.ts +++ b/frontend/utilities/software_install_scripts.ts @@ -11,7 +11,7 @@ import installDeb from "../../pkg/file/scripts/install_deb.sh"; * getInstallScript returns a string with a script to install the * provided software. * */ -const getInstallScript = (fileName: string): string => { +const getDefaultInstallScript = (fileName: string): string => { const extension = fileName.split(".").pop(); switch (extension) { case "pkg": @@ -27,4 +27,4 @@ const getInstallScript = (fileName: string): string => { } }; -export default getInstallScript; +export default getDefaultInstallScript; diff --git a/frontend/utilities/software_uninstall_scripts.ts b/frontend/utilities/software_uninstall_scripts.ts new file mode 100644 index 0000000000..041d2519c4 --- /dev/null +++ b/frontend/utilities/software_uninstall_scripts.ts @@ -0,0 +1,30 @@ +// @ts-ignore +import uninstallPkg from "../../pkg/file/scripts/uninstall_pkg.sh"; +// @ts-ignore +import uninstallMsi from "../../pkg/file/scripts/uninstall_msi.ps1"; +// @ts-ignore +import uninstallExe from "../../pkg/file/scripts/uninstall_exe.ps1"; +// @ts-ignore +import uninstallDeb from "../../pkg/file/scripts/uninstall_deb.sh"; + +/* + * getUninstallScript returns a string with a script to uninstall the + * provided software. + * */ +const getDefaultUninstallScript = (fileName: string): string => { + const extension = fileName.split(".").pop(); + switch (extension) { + case "pkg": + return uninstallPkg; + case "msi": + return uninstallMsi; + case "deb": + return uninstallDeb; + case "exe": + return uninstallExe; + default: + throw new Error(`unsupported file extension: ${extension}`); + } +}; + +export default getDefaultUninstallScript; diff --git a/frontend/utilities/strings/stringUtils.tests.ts b/frontend/utilities/strings/stringUtils.tests.ts index 25755873ff..4ebc69c632 100644 --- a/frontend/utilities/strings/stringUtils.tests.ts +++ b/frontend/utilities/strings/stringUtils.tests.ts @@ -1,4 +1,10 @@ -import { enforceFleetSentenceCasing, pluralize } from "./stringUtils"; +import { + enforceFleetSentenceCasing, + pluralize, + strToBool, + stripQuotes, + isIncompleteQuoteQuery, +} from "./stringUtils"; describe("string utilities", () => { describe("enforceFleetSentenceCasing utility", () => { @@ -44,4 +50,45 @@ describe("string utilities", () => { expect(pluralize(100, "hero")).toEqual("heros"); }); }); + + describe("strToBool utility", () => { + it("converts 'true' to true and 'false' to false", () => { + expect(strToBool("true")).toBe(true); + expect(strToBool("false")).toBe(false); + }); + + it("returns false for undefined, null, or empty string", () => { + expect(strToBool(undefined)).toBe(false); + expect(strToBool(null)).toBe(false); + expect(strToBool("")).toBe(false); + }); + }); + + describe("stripQuotes utility", () => { + it("removes matching single or double quotes from the start and end of a string", () => { + expect(stripQuotes('"Hello, World!"')).toEqual("Hello, World!"); + expect(stripQuotes("'Hello, World!'")).toEqual("Hello, World!"); + }); + it("does not modify a string without quotes or mismatched quotes", () => { + expect(stripQuotes("No quotes here")).toEqual("No quotes here"); + expect(stripQuotes(`'Mismatched quotes"`)).toEqual(`'Mismatched quotes"`); + }); + }); + + describe("isIncompleteQuoteQuery utility", () => { + it("returns true for a string starting with a quote but not ending with one", () => { + expect(isIncompleteQuoteQuery('"incomplete')).toBe(true); + expect(isIncompleteQuoteQuery("'incomplete")).toBe(true); + }); + + it("returns false for a string with matching quotes", () => { + expect(isIncompleteQuoteQuery('"complete"')).toBe(false); + expect(isIncompleteQuoteQuery("'complete'")).toBe(false); + }); + + it("returns false for a string without any quotes or an empty string", () => { + expect(isIncompleteQuoteQuery("no quotes")).toBe(false); + expect(isIncompleteQuoteQuery("")).toBe(false); + }); + }); }); diff --git a/frontend/utilities/strings/stringUtils.ts b/frontend/utilities/strings/stringUtils.ts index 6d0c8756fe..7a679b933b 100644 --- a/frontend/utilities/strings/stringUtils.ts +++ b/frontend/utilities/strings/stringUtils.ts @@ -75,8 +75,30 @@ export const pluralize = ( return `${root}${count !== 1 ? pluralSuffix : singularSuffix}`; }; +export const strToBool = (str?: string | null) => { + return str ? JSON.parse(str) : false; +}; + +export const stripQuotes = (string: string) => { + // Regular expression to match quotes at the start and end of the string + const quoteRegex = /^([''""])([\s\S]*?)(\1)$/; + + // If the string matches the regex, return the content between the quotes + // Otherwise, return the original string + const match = string.match(quoteRegex); + return match ? match[2] : string; +}; + +export const isIncompleteQuoteQuery = (str: string) => { + const pattern = /^(['"])(?!.*\1$)/; + return pattern.test(str); +}; + export default { capitalize, capitalizeRole, pluralize, + strToBool, + stripQuotes, + isIncompleteQuoteQuery, }; diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index b8f274ee17..cf827047b7 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -9,9 +9,10 @@ import { HOSTS_QUERY_PARAMS, MacSettingsStatusQueryParam, } from "services/entities/hosts"; -import { isValidSoftwareInstallStatus } from "interfaces/software"; +import { isValidSoftwareAggregateStatus } from "interfaces/software"; +import { API_ALL_TEAMS_ID } from "interfaces/team"; -type QueryValues = string | number | boolean | undefined | null; +export type QueryValues = string | number | boolean | undefined | null; export type QueryParams = Record; type FilteredQueryValues = string | number | boolean; type FilteredQueryParams = Record; @@ -45,6 +46,30 @@ interface IMutuallyExclusiveHostParams { bootstrapPackageStatus?: BootstrapPackageStatus; } +export const parseQueryValueToNumberOrUndefined = ( + value: QueryValues, + min?: number, + max?: number +): number | undefined => { + const isWithinRange = (num: number) => { + if (min !== undefined && max !== undefined) { + return num >= min && num <= max; + } + return true; // No range check if min or max is undefined + }; + + if (typeof value === "number") { + return isWithinRange(value) ? value : undefined; + } + if (typeof value === "string") { + const parsedValue = parseFloat(value); + return !isNaN(parsedValue) && isWithinRange(parsedValue) + ? parsedValue + : undefined; + } + return undefined; +}; + const reduceQueryParams = ( params: string[], value: FilteredQueryValues, @@ -95,10 +120,9 @@ export const reconcileSoftwareParams = ({ | "softwareStatus" >) => { if ( - isValidSoftwareInstallStatus(softwareStatus) && + isValidSoftwareAggregateStatus(softwareStatus) && softwareTitleId && - teamId && - teamId > 0 + teamId !== API_ALL_TEAMS_ID ) { return { software_title_id: softwareTitleId, diff --git a/go.mod b/go.mod index f735a2244b..0dd27931fb 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/fleetdm/fleet/v4 -go 1.22.4 +go 1.23.1 require ( - cloud.google.com/go/pubsub v1.36.1 + cloud.google.com/go/pubsub v1.37.0 fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d github.com/AbGuthrie/goquery/v2 v2.0.1 github.com/DATA-DOG/go-sqlmock v1.5.0 @@ -14,11 +14,12 @@ require ( github.com/XSAM/otelsql v0.10.0 github.com/andygrunwald/go-jira v1.16.0 github.com/antchfx/xmlquery v1.3.14 + github.com/apex/log v1.9.0 github.com/aws/aws-sdk-go v1.44.288 github.com/beevik/etree v1.3.0 github.com/beevik/ntp v0.3.0 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb - github.com/briandowns/spinner v1.13.0 + github.com/briandowns/spinner v1.23.1 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.3.0 github.com/clbanning/mxj v1.8.4 @@ -26,7 +27,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dgraph-io/badger/v2 v2.2007.4 github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e - github.com/docker/docker v26.1.4+incompatible + github.com/docker/docker v26.1.5+incompatible github.com/docker/go-units v0.5.0 github.com/doug-martin/goqu/v9 v9.18.0 github.com/e-dard/netbug v0.0.0-20151029172837-e64d308a0b20 @@ -65,7 +66,7 @@ require ( github.com/kevinburke/go-bindata v3.24.0+incompatible github.com/kolide/launcher v1.0.12 github.com/lib/pq v1.10.9 - github.com/macadmins/osquery-extension v1.1.3-0.20240530154548-05bb97403086 + github.com/macadmins/osquery-extension v1.2.1 github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e github.com/mattn/go-sqlite3 v1.14.22 github.com/micromdm/micromdm v1.9.0 @@ -89,11 +90,12 @@ require ( github.com/rs/zerolog v1.32.0 github.com/russellhaering/goxmldsig v1.2.0 github.com/saferwall/pe v1.5.2 - github.com/sassoftware/relic/v7 v7.6.2 + github.com/sassoftware/relic/v8 v8.0.1 github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 github.com/sethvargo/go-password v0.3.0 github.com/shirou/gopsutil/v3 v3.24.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa github.com/spf13/cast v1.4.1 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.10.0 @@ -101,7 +103,7 @@ require ( github.com/theupdateframework/go-tuf v0.5.2 github.com/throttled/throttled/v2 v2.8.0 github.com/tj/assert v0.0.3 - github.com/ulikunitz/xz v0.5.11 + github.com/ulikunitz/xz v0.5.12 github.com/urfave/cli/v2 v2.23.5 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 github.com/ziutek/mymysql v1.5.4 @@ -109,7 +111,6 @@ require ( go.elastic.co/apm/module/apmsql/v2 v2.4.3 go.elastic.co/apm/v2 v2.4.3 go.etcd.io/bbolt v1.3.9 - go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 go.opentelemetry.io/otel v1.28.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 @@ -125,7 +126,7 @@ require ( golang.org/x/sys v0.21.0 golang.org/x/text v0.16.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d - google.golang.org/api v0.169.0 + google.golang.org/api v0.178.0 google.golang.org/grpc v1.64.1 gopkg.in/guregu/null.v3 v3.5.0 gopkg.in/ini.v1 v1.67.0 @@ -136,11 +137,13 @@ require ( ) require ( - cloud.google.com/go v0.112.1 // indirect + cloud.google.com/go v0.112.2 // indirect + cloud.google.com/go/auth v0.3.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/compute/metadata v0.3.0 // indirect - cloud.google.com/go/iam v1.1.6 // indirect - cloud.google.com/go/kms v1.15.7 // indirect - cloud.google.com/go/storage v1.38.0 // indirect + cloud.google.com/go/iam v1.1.8 // indirect + cloud.google.com/go/kms v1.15.9 // indirect + cloud.google.com/go/storage v1.39.1 // indirect code.gitea.io/sdk/gitea v0.15.0 // indirect dario.cat/mergo v1.0.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect @@ -166,29 +169,28 @@ require ( github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/agnivade/levenshtein v1.1.1 // indirect github.com/akavel/rsrc v0.10.2 // indirect github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect github.com/antchfx/xpath v1.2.2 // indirect github.com/apache/thrift v0.18.1 // indirect - github.com/apex/log v1.9.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/atc0005/go-teams-notify/v2 v2.6.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect - github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect - github.com/aws/aws-sdk-go-v2/service/kms v1.27.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect - github.com/aws/smithy-go v1.19.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.31.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect + github.com/aws/smithy-go v1.20.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/c-bata/go-prompt v0.2.3 // indirect github.com/caarlos0/ctrlc v1.0.0 // indirect @@ -197,7 +199,7 @@ require ( github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/cloudflare/circl v1.3.7 // indirect + github.com/cloudflare/circl v1.3.8 // indirect github.com/containerd/log v0.1.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect @@ -236,7 +238,7 @@ require ( github.com/google/s2a-go v0.1.7 // indirect github.com/google/wire v0.5.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.4 // indirect github.com/goreleaser/chglog v0.1.2 // indirect github.com/goreleaser/fileglob v1.2.0 // indirect github.com/gorilla/schema v1.4.1 // indirect @@ -257,7 +259,7 @@ require ( github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.17.7 // indirect + github.com/klauspost/compress v1.17.8 // indirect github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.5 // indirect @@ -272,7 +274,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0-rc6 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/oschwald/maxminddb-golang v1.10.0 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect @@ -308,6 +310,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.elastic.co/apm/module/apmhttp/v2 v2.3.0 // indirect go.elastic.co/fastjson v1.1.0 // indirect + go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect @@ -320,7 +323,7 @@ require ( golang.org/x/term v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/protobuf v1.34.2 // indirect diff --git a/go.sum b/go.sum index 7eceaae2aa..f6cb680a80 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,12 @@ cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= -cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4= +cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw= +cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= +cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs= +cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -45,19 +49,19 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo= -cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= -cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= +cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0= +cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE= cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c= -cloud.google.com/go/kms v1.15.7 h1:7caV9K3yIxvlQPAcaFffhlT7d1qpxjB1wHBtjWa13SM= -cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI= +cloud.google.com/go/kms v1.15.9 h1:ouZjTxCqDNEdxWfaAAbRzG22s/2iewRw6JPARQL+0vc= +cloud.google.com/go/kms v1.15.9/go.mod h1:5v/R/RRuBUVO+eJioGcqENr3syh8ZqNn1y1Wc9DjM+4= cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/pubsub v1.16.0/go.mod h1:6A8EfoWZ/lUvCWStKGwAWauJZSiuV0Mkmu6WilK/TxQ= -cloud.google.com/go/pubsub v1.36.1 h1:dfEPuGCHGbWUhaMCTHUFjfroILEkx55iUmKBZTP5f+Y= -cloud.google.com/go/pubsub v1.36.1/go.mod h1:iYjCa9EzWOoBiTdd4ps7QoMtMln5NwaZQpK1hbRfBDE= +cloud.google.com/go/pubsub v1.37.0 h1:0uEEfaB1VIJzabPpwpZf44zWAKAme3zwKKxHk7vJQxQ= +cloud.google.com/go/pubsub v1.37.0/go.mod h1:YQOQr1uiUM092EXwKs56OPT650nwnawc+8/IjoUeGzQ= cloud.google.com/go/secretmanager v0.1.0/go.mod h1:3nGKHvnzDUVit7U0S9KAKJ4aOsO1xtwRG+7ey5LK1bM= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= @@ -65,8 +69,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4= -cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= -cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY= +cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY= +cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o= cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g= code.gitea.io/gitea-vet v0.2.1/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.15.0 h1:tsNhxDM/2N1Ohv1Xq5UWrht/esg0WmtRj4wsHVHriTg= @@ -174,8 +178,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a h1:W6RrgN/sTxg1msqzFFb+G80MFmpjMw61IU+slm+wln4= github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4= github.com/ProtonMail/gopenpgp/v2 v2.2.2 h1:u2m7xt+CZWj88qK1UUNBoXeJCFJwJCZ/Ff4ymGoxEXs= @@ -243,45 +247,45 @@ github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm github.com/aws/aws-sdk-go v1.44.288 h1:Ln7fIao/nl0ACtelgR1I4AiEw/GLNkKcXfCaHupUW5Q= github.com/aws/aws-sdk-go v1.44.288/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= -github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= +github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY= -github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= -github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= +github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= +github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE= github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= -github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0/go.mod h1:CpNzHK9VEFUCknu50kkB8z58AH2B5DvPP7ea1LHve/Y= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2/go.mod h1:BQV0agm+JEhqR+2RT5e1XTFIDcAAV0eW6z2trp+iduw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0/go.mod h1:R1KK+vY8AfalhG1AOu5e35pOD2SdoPKQCFLTvnxiohk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= github.com/aws/aws-sdk-go-v2/service/kms v1.5.0/go.mod h1:w7JuP9Oq1IKMFQPkNe3V6s9rOssXzOVEMNEqK1L1bao= -github.com/aws/aws-sdk-go-v2/service/kms v1.27.9 h1:W9PbZAZAEcelhhjb7KuwUtf+Lbc+i7ByYJRuWLlnxyQ= -github.com/aws/aws-sdk-go-v2/service/kms v1.27.9/go.mod h1:2tFmR7fQnOdQlM2ZCEPpFnBIQD1U8wmXmduBgZbOag0= +github.com/aws/aws-sdk-go-v2/service/kms v1.31.0 h1:yl7wcqbisxPzknJVfWTLnK83McUvXba+pz2+tPbIUmQ= +github.com/aws/aws-sdk-go-v2/service/kms v1.31.0/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.6.0/go.mod h1:B+7C5UKdVq1ylkI/A6O8wcurFtaux0R1njePNPtKwoA= github.com/aws/aws-sdk-go-v2/service/ssm v1.10.0/go.mod h1:4dXS5YNqI3SNbetQ7X7vfsMlX6ZnboJA2dulBwJx7+g= github.com/aws/aws-sdk-go-v2/service/sso v1.4.0/go.mod h1:+1fpWnL96DL23aXPpMGbsmKe8jLTEfbjuQoA4WS1VaA= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak= github.com/aws/aws-sdk-go-v2/service/sts v1.7.0/go.mod h1:0qcSMCyASQPN2sk/1KQLQ2Fh6yq8wm0HSDAimPhzCoM= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= -github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.3.0 h1:hQTc+pylzIKDb23yYprodCWWTt+ojFfUZyzU09a/hmU= @@ -297,8 +301,8 @@ 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/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/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= +github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytecodealliance/wasmtime-go v0.36.0 h1:B6thr7RMM9xQmouBtUqm1RpkJjuLS37m6nxX+iwsQSc= github.com/bytecodealliance/wasmtime-go v0.36.0/go.mod h1:q320gUxqyI8yB+ZqRuaJOEnGkAnHh6WtJjMaT2CW4wI= @@ -336,8 +340,8 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= -github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= +github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -397,8 +401,8 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v26.1.4+incompatible h1:vuTpXDuoga+Z38m1OZHzl7NKisKWaWlhjQk7IDPSLsU= -github.com/docker/docker v26.1.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g= +github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -668,8 +672,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksP github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA= -github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc= +github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= +github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/goreleaser/chglog v0.1.2 h1:tdzAb/ILeMnphzI9zQ7Nkq+T8R9qyXli8GydD8plFRY= github.com/goreleaser/chglog v0.1.2/go.mod h1:tTZsFuSZK4epDXfjMkxzcGbrIOXprf0JFp47BjIr3B8= @@ -805,8 +809,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= -github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab h1:KVR7cs+oPyy85i+8t1ZaNSy1bymCy5FuWyt51pdrXu4= github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab/go.mod h1:OYYulo9tUqRadRLwB0+LE914sa1ui2yL7OrcU3Q/1XY= github.com/kolide/launcher v1.0.12 h1:f2uT1kKYGIbj/WVsHDc10f7MIiwu8MpmgwaGaT7D09k= @@ -834,8 +838,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/macadmins/osquery-extension v1.1.3-0.20240530154548-05bb97403086 h1:pom8HjoknG5GF+cqeTP+2sXIn8Tl8Y7cinlPNv0O+LY= -github.com/macadmins/osquery-extension v1.1.3-0.20240530154548-05bb97403086/go.mod h1:q0BnBuYocHBRB+m3AQwdQNETH5a2KzVT3S8TKMHo9Lk= +github.com/macadmins/osquery-extension v1.2.1 h1:p7tAAhfEjUjoMQJNb+X7Qc3FraVqGZqMhZ1BYJbrlaw= +github.com/macadmins/osquery-extension v1.2.1/go.mod h1:q0BnBuYocHBRB+m3AQwdQNETH5a2KzVT3S8TKMHo9Lk= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -944,8 +948,8 @@ github.com/open-policy-agent/opa v0.44.0/go.mod h1:YpJaFIk5pq89n/k72c1lVvfvR5uop github.com/opencensus-integrations/ocsql v0.1.1/go.mod h1:ozPYpNVBHZsX33jfoQPO5TlI5lqh0/3R36kirEqJKAM= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0-rc6 h1:XDqvyKsJEbRtATzkgItUqBA7QHk58yxX1Ov9HERHNqU= -github.com/opencontainers/image-spec v1.1.0-rc6/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs= github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw= github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg= @@ -1024,8 +1028,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/saferwall/pe v1.5.2 h1:h5lLtLsyxGHQ9dN6cd8EfeLEBEo5gdqJpkuw4o4vTMY= github.com/saferwall/pe v1.5.2/go.mod h1:SNzv3cdgk8SBI0UwHfyTcdjawfdnN+nbydnEL7GZ25s= -github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= -github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/sassoftware/relic/v8 v8.0.1 h1:uYUoaoTQMs67up8/46NgrSxSftgfY4VWBusDVg56k7I= +github.com/sassoftware/relic/v8 v8.0.1/go.mod h1:s/MwugRcovgYcNJNOyvLfqRHDX7iArHtFtUR9kEodz8= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg= github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= @@ -1056,6 +1060,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/slack-go/slack v0.9.4 h1:C+FC3zLxLxUTQjDy2RZeMHYon005zsCROiZNWVo+opQ= github.com/slack-go/slack v0.9.4/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa h1:FtxzVccOwaK+bK4bnWBPGua0FpCOhrVyeo6Fy9nxdlo= +github.com/smallstep/pkcs7 v0.0.0-20240723090913-5e2c6a136dfa/go.mod h1:SoUAr/4M46rZ3WaLstHxGhLEgoYIDRqxQEXLOmOEB0Y= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= @@ -1140,8 +1146,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= -github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw= github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/vartanbeno/go-reddit/v2 v2.0.0 h1:fxYMqx5lhbmJ3yYRN1nnQC/gecRB3xpUS2BbG7GLpsk= @@ -1653,8 +1659,8 @@ google.golang.org/api v0.52.0/go.mod h1:Him/adpjt0sxtkWViy0b6xyKW/SD71CwdJ7HqJo7 google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= -google.golang.org/api v0.169.0 h1:QwWPy71FgMWqJN/l6jVlFHUa29a7dcUy02I8o799nPY= -google.golang.org/api v0.169.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg= +google.golang.org/api v0.178.0 h1:yoW/QMI4bRVCHF+NWOTa4cL8MoWL3Jnuc7FlcFF91Ok= +google.golang.org/api v0.178.0/go.mod h1:84/k2v8DFpDRebpGcooklv/lais3MEfqpaBLA12gl2U= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1724,8 +1730,8 @@ google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210825212027-de86158e7fda/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= -google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae h1:HjgkYCl6cWQEKSHkpUp4Q8VB74swzyBwTz1wtTzahm0= +google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:i4np6Wrjp8EujFAUn0CM0SH+iZhY1EbrfzEIJbFkHFM= google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md deleted file mode 100644 index 81ba9c82be..0000000000 --- a/handbook/business-operations/README.md +++ /dev/null @@ -1,553 +0,0 @@ -# Business Operations -This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. - -## Team -| Role | Contributor(s) | -|:------------------------------|:-----------------------------------------------------------------------------------------------------------| -| Head of Business Operations | [Joanne Stableford](https://www.linkedin.com/in/joanne-stableford/) _([@jostableford](https://github.com/JoStableford))_ -| Business Operations Engineer | [Nathan Holliday](https://www.linkedin.com/in/nathanael-holliday/) _([@hollidayn](https://github.com/hollidayn))_
    [Isabell Reedy](https://www.linkedin.com/in/isabell-reedy-202aa3123/) _([@ireedy](https://github.com/ireedy))_ - -## Contact us -- To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in [#g-business-operations](https://fleetdm.slack.com/archives/C047N5L6EGH). - - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none) for this department, including pending tasks and the status of new requests. - - -## Responsibilities -The Business Operations department is directly responsible for people operations, finance + invoicing, tax, compliance, and legal + deal desk. - - -### Run payroll -Many of these processes are automated, but it's vital to check Gusto and Plane manually for accuracy. - - Salaried fleeties are automated in Gusto and Plane. - - Hourly fleeties and consultants are a manual process each month in Gusto and Plane. - -| Payroll type | What to use | DRI | -|:-----------------------------|:-----------------------------|:-----------------------------| -| [Commissions and ramp](https://fleetdm.com/handbook/business-operations#run-us-commission-payroll) | "Off-cycle - Commission" payroll | Head of Business Operations -| Sign-on bonus | "Bonus" payroll | Head of Business Operations -| Performance bonus | "Bonus" payroll | Head of Business Operations -| Accelerations (quarterly) | "Off-cycle - Commission" payroll | Head of Business Operations -| [US contractor payroll](https://fleetdm.com/handbook/business-operations#run-us-contractor-payroll) | "Off-cycle" payroll | Head of Business Operations - -### Reconcile monthly recurring expenses -Recurring monthly or annual expenses, such as the tools we use throughout Fleet, are tracked as recurring, non-personnel expenses in ["🧮 The Numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) _(¶confidential Google Sheet)_, along with their payment source. Reconciliation of recurring expenses happens monthly. - -> Use this spreadsheet as the source of truth. Always make changes to it first before adding or removing a recurring expense. Only track significant expenses. (Other things besides amount can make a payment significant; like it being an individualized expense, for example.) - - -### Access a background check -All Fleet team members undergo a background check provided through [Vetty](https://vetty.co/). Only the most recent background checks appear on the home page of Vetty's dashboard. To access a complete list of background checks run in Vetty, scroll down to the bottom of the candidates page and click "View Historical". - - -### Register Fleet as an employer with a new state -Fleet must register as an employer in any state where we hire new teammates. To do this, complete the following steps in Gusto: -1. After a new teammate completes their Gusto profile, the Business Operations department will be prompted to approve it for payroll. Sign in to your Gusto admin account and begin the approval process. -2. Select "yes" when prompted to file a new hire report and complete the approval process. -3. Once the profile is approved, navigate to Tax setup and select the state you’d like to register Fleet in. -4. Select “Have us register for you” and then “Start registration.” -5. Verify, add, and amend any company information to ensure accuracy. -6. Select “Send registration” and authorize payment for the specified amount. CorpNet will then send an email with next steps, which vary by state. -7. Update the [list of states that Fleet is currently registered with as an employer](https://fleetdm.com/handbook/business-operations#review-state-employment-tax-filings-for-the-previous-quarter). - - -### Process an email from a state agency -From time to time, you may get notices via email (or in the mail) from state agencies regarding Fleet's withholding and/or unemployment tax accounts. You can resolve some of these notices on your own by verifying and/or updating the settings in your Gusto account. - -If the notice is regarding an upcoming change to your deposit schedule or unemployment tax rate, make the required change in Gusto, such as: -- Update your unemployment tax rate. -- Update your federal deposit schedule. -- Update your state deposit schedule. - -In Gusto, you can click **How to review your notice** to help you understand what kind of notice you received and what additional action you can take to help speed up the time it takes to resolve the issue. - -> **Note:** Many agencies do not send notices to Gusto directly, so it’s important that you read and take action before any listed deadlines or effective dates of requested changes, in case you have to do something. If you can't resolve the notice on your own, are unsure what the notice is in reference to, or the tax notice has a missing payment or balance owed, follow the steps in the Report and upload a tax notice in Gusto. - -Every quarter, payroll and tax filings are due for each state. Gusto can handle these automatically if Third-party authorization (TPA) is enabled. Each state is unique and Gusto has a library of [State registration and resources](https://support.gusto.com/hub/Employers-and-admins/Taxes-forms-and-compliance/State-registration-and-resources) available to review. You will need to grant Third-party authorization (TPA) per state and this should be checked quarterly before the filing due dates to ensure that Gusto can file on time. --> - - -### Review state employment tax filings for the previous quarter - -Every quarter, payroll and tax filings are due for each state. Gusto automates this process, however there are often delays or quirks between Gusto's submission and the state receiving the filings. -To mitigate the risk of penalties and to ensure filings occur as expected, follow these steps in the first month of the new quarter, verifying past quarter submission: -1. Create an issue to "Review state filings for the previous quarter". -2. Copy this text block into the issue to track progress by state: - - -``` -States checked: -- [ ] California -- [ ] Colorado -- [ ] Connecticut -- [ ] Florida -- [ ] Georgia -- [ ] Hawaii -- [ ] Illinois -- [ ] Kansas -- [ ] Maryland -- [ ] Massachusetts -- [ ] New York -- [ ] Ohio -- [ ] Oregon -- [ ] Pennsylvania -- [ ] Rhode Island -- [ ] Tennessee -- [ ] Texas -- [ ] Utah -- [ ] Virginia -- [ ] Washington -- [ ] Washington, DC -- [ ] West Virginia -- [ ] Wisconsin -``` - - -3. Login to Gusto and navigate to "Taxes and compliance", then "Tax documents". -4. Login to each State portal (using the details saved in 1Password) and verify that the portal has received the automated submission from Gusto. -5. Check off states that are correct, and use comments to explain any quirks or remediation that's needed. - - -### Inform managers about hours worked - -Every Friday at 2:00 PM CT, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location. - -Here's how: - -1. For each hourly core team member in Gusto or Plane.com, find the DRI by checking [who they report to](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0). - - If any direct report is hourly in Plane.com and submits hours monthly, still list them and provide an explanation. -2. Consultants submit their hours through Gusto (US consultants) or Plane.com (international consultants) and require DRI approval. Find the DRI using the [Business Operations KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). -3. Send the teammate's DRI a direct message in Slack with a screenshot of the HRIS portal, showing hours logged since last Saturday at midnight, and ask them to confirm the hours are expected. Ensure the screenshot does not include compensation information. -4. The following Monday, check for updates to logged hours and ensure the KPI sheet aligns with HRIS records. - - If there are discrepancies between what was previously reported, reconfirm logged hours with the teammate's DRI and update the KPI sheet to reflect the correct amount. - - -### 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: -1. Add the amount to be paid to the "Gross" line. -2. Review hours _("Time tools > Time tracking")_ -3. Adjust time frame to match current payroll period (the 27th through 26th of the month) -4. Sync hours and run contractor payroll. - -### Create an invoice -To create a new invoice for a Fleet customer, follow these steps: -1. Go to the [invoice folder in google drive](https://drive.google.com/drive/folders/11limC_KQYNYQPApPoXN0CplHo_5Qgi2b?usp=drive_link). -2. Create a copy of the invoice template, and title the copy `[invoice number] Fleet invoice - [customer name]`. - - The invoice number follows the format of `YYMMDD[daily issued invoice number]`, where the daily issued invoice number should equal `01` if it's the first invoice issued that day, `02` if it's the second, etc. -3. Edit the new invoice to reflect details from the signed subscription agreement (and PO if required). - - Enter the invoice number (and PO number if required) into the top right section of the invoice. - - Update the date of the invoice to reflect the current date. - - Make sure the payment terms match the signed subscription agreement. - - Copy the customer address from the signed subscription agreement and input it in the "Bill to" section of the invoice. - - Copy the "Billing contact" email from the signed subscription agreement and add it to the last line of the "Bill to" address. - - Make sure the start and end dates of the contract and amount match the subscription agreement. - - If professional services are included in the subscription agreement, include as a separate line in the invoice, and ensure the amounts total correctly. - - Ensure the "Notes" section has wiring instructions for payment via SVB. -4. Download the completed invoice as a PDF. -5. Send the PDF to the billing contact from the "Bill to" section of the invoice and cc [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays). Use the following template for the email: - -``` -Subject: Invoice for Fleet Device Management [invoice number] -Hello, - -I've attached the invoice for [customer name]'s purchase of Fleet Device Management's premium subscription. -For payment instructions please refer to your invoice, and reach out to [insert Fleet's billing address] with any questions. - -Thanks, -[name] -``` - -6. Update the opportunity and the opportunity billing cycle in Salesforce to include the "Invoice date" as the day the invoice was sent. -8. Notify the AE/CSM that the invoice has been sent. - -> Certain vendors require invoices submitted via a payment portal (such as Coupa). Once you've generated the invoice using the steps above, upload it to the relevant payment portal and email the billing contact to let them know you've submitted the invoice. - - -### Communicate the status of customer financial actions -This reporting is performed to update the status of open or upcoming customer actions regarding the financial health of the opportunity. To complete the report: -1. Go to this [report folder](https://fleetdm.lightning.force.com/lightning/r/Folder/00lUG000000DstpYAC/view?queryScope=userFolders) in SFDC. The three reports will provide the data used in the report. -2. Copy the template below and paste it into the [#g-sales slack channel](https://fleetdm.slack.com/archives/C030A767HQV) and complete all "todos" using the data from Salesforce before sending. - -``` -Weekly revenue report - [@`todo: CRO` and @`todo: CEO`] -- Number accounts with outstanding balances = `todo` -- Number of customers awaiting invoices = `todo` -- Number of past-due renewals = `todo` -``` - -3. Send payment reminders via email to all outstanding accounts by responding to the invoice email initially sent to the customer. - -``` -Hello, -This is a reminder that you have an outstanding balance due for your Fleet Device Management premium subscription. -We have included the invoice here for your convenience. -For payment instructions please refer to your invoice, and reach out to [Fleet's billing contact] with any questions. - -Thanks, -[name] -``` -4. If any accounts will become overdue within a week, reply in thread to the slack post, mention the opportunity owner of the account, and ask them to notify their contact that Fleet is still awaiting payment. -5. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Business Operations board. - - -### Run US commission payroll -1. Update individual teammates commission calculators (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with new revenue from any deals that are closed-won (have a subscription agreement signed by both parties) and have a **close date** within the previous month. - - Verify closed-won deal numbers with CRO to ensure any agreed upon exceptions are captured (eg: CRO approves an AE to receive commission on a renewal deal due to cross-sell). -2. In the "Monthly commission payroll party" meeting, present the commission calculations for Fleeties receiving commission for approval. - - If there are any quarterly accelerators due for the teammate receiving commission, ensure the individual total includes both the monthly and the quarterly amount. -3. After the amounts are approved in the meeting, process the commission payroll. - - Use the off-cycle payroll option in Gusto. Be sure to classify the payment as "Commission" in the "other earnings" field and not the generic "Bonus." -4. Once commission payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the commission as paid. - -### Run international commission payroll -1. Follow the steps in [run US commission payroll](https://fleetdm.com/handbook/business-operations#run-us-commission-payroll) to have the commission amounts approved by the CRO. -2. After the amounts are approved in the "Monthly commission payroll party", navigate to Help > Ask a question in Plane to request a commission payment for the teammate. -3. Send a message using the following template - - ``` - Hello, - I’d like to run an off-cycle commission payment for [teammate’s full name] for the period of [commission period]. - The amount of [USD amount] should be paid with their next payroll. - Please let me know if you need any additional information to process this request. - - Thanks, - [name] - ``` - -4. Once Plane confirms the payroll change has been actioned, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit#gid=928324236) to mark the commission as paid. - - -### Run quarterly or annual employee bonus payroll -1. Update individual teammate bonus calculator (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with relevant metrics. - - Bonus plans will have details specified on how to measure success, with most drawing from the [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) or from linked SFDC reports. If unsure where to pull achievement metrics from, contact teammate's manager to clarify. -2. In the "Monthly commission payroll party" meeting, present the bonus calculations for Fleeties receiving bonus for approval. -3. After the amounts are approved in the meeting, process the bonus payroll. - - Use the off-cycle payroll option in Gusto and be sure to classify the payment as "Bonus". - - For international teammates, you may need to use the "Help" function, or email support to notify Plane of the amount needing to be paid. -4. Once bonus payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the bonus as paid. - - -### Convert a Fleetie to a consultant -If a Fleetie decides they want to move to being a [consultant](https://fleetdm.com/handbook/company/leadership#consultants), either the Fleetie or their manager need to create a [custom issue for the BizOps team](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) to notify them of the change. -Once notified, BizOps takes the following steps: -1. Confirm the following details with the Fleetie: - - Date of change - - Term of consultancy (time period) - - Hours/capacity expected (hours per week or month) - - Confirm hourly rate -2. Once details are confirmed, use the information given to create the consulting agreement for the Fleetie (either in docusign (US-based) or via Plane (international)), and send to their personal email for signature. Once signed, save in Fleetie's [employee file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -3. Schedule the Fleetie's final day in HRIS (Gusto or Plane). -4. Update final day in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. -5. Create an [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md) for the Fleetie converting to a consultant, and confirm with their manager if there is a need to retain any tools or access while they are a consultant (default to removing all access from Fleet email, and migrating to personal email for Slack and other tools unless there is a business case to retain the Fleet email and associated tool access). -6. Follow the offboarding issue for next steps, including communicating to teammates and updating equity plan. - - -### Update personnel details -When a Fleetie, consultant or advisor requests an update to their personnel details (name, location, phone, etc), follow these steps to ensure accurate representation across systems. -1. Team member submits a [custom issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=custom-request.md&title=Request%3A+_______________________) to update their personnel details (or BizOps team creates if the request comes via email or is sensitive and needs a classified issue). - - If change is for a primary identification or contact method, ask for evidence of change and capture in [employee's personnel file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -2. BizOps makes change to HRIS (Gusto or Plane) to reflect change. - - Note: if making the change requires follow up steps, resolve those steps to action the change. -3. Once change is effected in HRIS, BizOps makes changes to ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. -4. If required, BizOps makes any relevant changes to [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). -5. If required, BizOps makes any relevant changes to the ["🗺️ Geographical factors"](https://docs.google.com/spreadsheets/d/1rCVCs-eOo-VSEG7fPLgdq5l7oSaActl5bewaWP7PnSE/edit#gid=1533353559) spreadsheet and follows through on any action items involving tax implications (i.e. registering with a new state for employer taxes). -6. 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). -7. The change is now actioned, notify the team member and close the issue. - -> Note: if the Fleetie is US based and has a qualifying life event that impacts benefit coverage, they can [follow the Gusto steps](https://support.gusto.com/article/100895878100000/Change-your-benefits-with-a-qualifying-life-event) to update their coverage elections. - - -### 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. -1. Update ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): - - 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. -2. Update the departmental handbook page with the change of job title -3. Update the relevant payroll/HRIS system. - - For updating Gusto (US-based Fleeties): - - Login to Gusto and navigate to "People > Team members". - - Find the Fleetie and select them to see their profile page. - - 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. -1. Update [🧑‍🚀 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. -2. If the person's department is changing, then update both departmental handbook pages to move the person to their new department: - - Remove the person from the "Team" section of the old department and add them to the "Team" section of the new department. -3. If the person's level of confidential access will change along with the change to their manager, then update that level of access: - - Update Google Workspace to make sure this person lives in the correct Google Group, removing them from the old and/or adding them to the new. - - Update 1password to remove this person from old vaults and/or add them to new vaults. - - For a team member moving from "classified" to "confidential" access, check Gusto, Plane, and other systems to remove their access. - -> **Note:** The Fleeties spreadsheet is the source of truth for who everyone's manager is and their job titles. - -### Recognize employee workiversaries - -At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/handbook/company/communications#workiversaries). To ensure this happens, take the following steps: - -1. Bimonthly, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the following two months. -2. Post in the #help-classifed Slack channel and cc the Head of Business Operations. Use the following template: - - - ``` - [Month] - [workiversary date (DD-MMM)] - [teammate name] - [number of years at Fleet] - ``` - - - The Apprentice to the CEO will also use this post to update the [All hands](https://fleetdm.com/handbook/company/communications#all-hands) deck. -3. On the day prior to a workiversary, send the teammate’s manager a DM on Slack: - - - ``` - Hey! Just a heads up, tomorrow is [teammate’s name] [number of years at Fleet] workiversary at Fleet. - BizOps were planning on posting something in the #random channel to recognize them, but I was wondering if you would like to instead? - ``` - - - > If a manager elects to post and hasn't done so by 2pm ET on the day of the workiversary, send them a friendly reminder and offer to post instead. - -4. If the manager has deferred to BizOps, schedule a Slack post for the following day to recognize the teammate's contributions at Fleet. If you’re unsure about what to post, take a look at what’s been [posted previously](https://docs.google.com/document/d/1Va4TYAs9Tb0soDQPeoeMr-qHxk0Xrlf-DUlBe4jn29Q/edit). - - - -### Prepare salary benchmarking information -1. 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. -2. Copy the template text and paste at the end of the document. -3. Fill in details as required, pulling from [🧑‍🚀 Fleeties spreadsheet](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) and [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) as required. -4. Use the teammate's information to benchmark in [Pave](https://www.pave.com/) (login details in 1Password). You can pattern match from previous benchmarking entries, and include all company assumtions. Add the direct link to the Pave benchmark. - - -### Update a team member's compensation -To [change a team member's compensation](https://fleetdm.com/handbook/company/communications#compensation-changes), follow these steps: -1. Create a copy of the ["Values assessment" template](https://docs.google.com/spreadsheets/d/1P5TyRV2v-YN0aR_X8vd8GksKcr3uHfUDdshqpVzamV8/edit?usp=drive_link) and move it to the team member's [personnel folder in Google Drive](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). -2. Share the values assessment document with the manager and ask them to perform the values assessment. -3. Once the values assessment is complete, [prepare salary benchmarking information](#prepare-salary-benchmarking-information) and set a meeting between the manager, head of department, and Head of Business Operations about level of job skill in relation to compensation benchmarking levels. -4. Schedule a new calendar event for the Head of Business Operations with the founders over an existing founders' 1:1 to discuss if an adjustment needs to be made to team member's compensation to align with benchmarking. During the 1:1 call, founders review values assessment, benchmarking for role and geography, and decide if there will be an adjustment. -5. Head of Business Operations will post in slack to `#help-classified` with the decision on compensation changes and effective date, if any. -6. Communicate via Slack DM the decision to the teammate's people manager, who will then communicate to their teammate. -7. Update the respective payroll platform (Gusto or Plane) by navigating to the personnel page, selecting salary field, and updating with an effective date that makes the next payroll. -8. Update the [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) (internal doc) by copying existing OTE to the bottom of the "Notes" cell, updating the OTE column with the new compensation information, and updating the "Last compensation change" column with the effective date from payroll platform. - -> If the company decides on an additional equity grant as part of a compensation change, note the previous equity and new situation in detail in the "Notes" column of the equity plan. Update the "Grant started?" column to "todo" which adds it to the queue for the next time grants are processed (quarterly). - -### Review Fleet's US company benefits - -Annually, around mid-year, Fleet will be prompted by Gusto to review company benefits. The goal is to keep changes minimal. Follow these steps: -1. Log in to your [Gusto admin account](https://gusto.com/). -2. Navigate to "Benefits" and select "Renewal survey". -3. Complete the survey questions, aiming for minimal changes. -4. Approximately 2-3 months after survery completion, Gusto will suggest plans based on Fleet's responses. Choose plans with minimal changes. -5. Gusto will offer these plans to employees during open enrollment, with new coverage starting 3-4 weeks afterward. - - -### Process monthly accounting -Create a [new montly accounting issue](https://github.com/fleetdm/confidential/issues/new/choose) for the current month and year named "Closing out YYYY-MM" in GitHub and complete all of the tasks in the issue. (This uses the [monthly accounting issue template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/5-monthly-accounting.md). - -- **SLA:** The monthly accounting issue should be completed and closed before the 7th of the month. -- The close date is tracked each month in [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit). -- **When is the issue created?** We create and close the monthly accounting issue for the previous month within the first 7 days of the following month. For example, the monthly accounting issue to close out the month of January is created promptly in February and closed before the end of the day, Feb 7th. A convenient trick is to create the issue on the first Friday of the month and close it ASAP. - - -### Respond to low credit alert -Fleet admins will receive an email alert when the usage of company cards for the month is aproaching the company credit limit. To avoid the limit being exceeded, a Brex admin will follow these steps: -1. Sign in to Fleet's Brex account. -2. On the landing page, use the "Move money" button to "Add funds to your Brex business accounts". -3. Select "Transfer from a connected account" and select the primary business account. -4. Choose the "One time" transfer option and process the transfer. - -No further action needs to be taken, the amount available for use will increase without disruption to regular processes. - -### Check franchise tax status -No later than the second month of every quarter, we check [Delaware divison of corporations](https://icis.corp.delaware.gov) to ensure that Fleet has paid the quarterly franchise tax amounts to remain in good standing with the state of Delaware. -- Go to the [DCIS - eCorp website](https://icis.corp.delaware.gov/ecorp/logintax.aspx?FilingType=FranchiseTax) and use the details in 1Password to look up Fleet's status. -- If no outstanding amounts: the tax has been paid. -- If outstanding amounts shown: ensure payment before due date to avoid penalties, interest, and entering bad standing. - - -### Check finances for quirks -Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up on quirks. -1. Check to make sure [bookkeeping quirks](https://docs.google.com/spreadsheets/d/1nuUPMZb1z_lrbaQEcgjnxppnYv_GWOTTo4FMqLOlsWg/edit?usp=sharing) are all accounted for and resolved or in progress toward resolution. -2. Check balance sheet and profit and loss statements (P&Ls) in QBO against the latest [monthly workbooks](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM) in Google Drive. Ensure reports are in the "accural" accounting method. -3. Reach out to Pilot with any differences or quirks, and ask them to resolve/provide clarity. This often will need to happen over a call to review sycnhronously. -4. Once quirks are resolved, note the day it was resolved in the spreadsheet. - - -### Report quarterly numbers in Chronograph -Follow these steps to perform quarterly reporting for Fleet's investors: -1. Login to Chronograph and upload our profit and loss statement (P&L), balance sheet and cash flow statements for CRV (all in one book saved in [Google Drive](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM). -2. Provide updated metrics for the following items using Fleet's [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). - - Headcount at end of the previous quarter. - - Starting ARR for the previous quarter. - - Total new ARR for the previous quarter. - - "Upsell ARR" (new ARR from expansions only- Chronograph defines "upsell" as price increases for any reason. - **- Fleet does not "upsell" anything; we deliver more value and customers enroll more hosts), downgrade ARR and churn ARR (if any) for the previous quarter.** - - Ending ARR for the previous quarter. - - Starting number of customers, churned customers, and the number of new customers Fleet gained during the previous quarter. - - Total amount of Fleet customers at the end of the previous quarter. - - Gross margin % - - How to calculate: (total revenue for the quarter - cost of goods sold for the quarter)/total revenue for the quarter (these metrics can be found in our books from Pilot). Chronograph will automatically conver this number to a %. - - Net dollar retention rate - - How to calculate: (starting ARR + new subscriptions and expansions - churn)/starting ARR. - - Cash burn - - How to calculate: start of quarter runway - end of quarter runway. - - -### Grant equity -Equity grants for new hires are queued up as part of the [hiring process](https://fleetdm.com/handbook/business-operations#hiring), then grants and consents are [batched and processed quarterly](https://github.com/fleetdm/confidential/issues/new/choose). - -Doing an equity grant involves: -- Executing a board consent -- The recipient and CEO signing paperwork about the stock options -- Updating the number of shares for the recipient in the equity plan -- Updating Carta to reflect the grant - -For the status of stock option grants, exercises, and all other _common stock_ including advisor, founder, and team member equity ownership, see [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). For information about investor ownership, see [Carta](https://app.carta.com/corporations/1234715/summary/). - -> Fleet's [equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) is the source of truth, not Carta. Neither are pro formas sent in an email attachment, even if they come from lawyers. -> -> Anyone can make mistakes, and none of us are perfect. Even when we triple check. Small mistakes in share counts can be hard to attribute, and can cause headaches and eat up nights of our CEO's and operations team's time. If you notice what might be a discrepancy between the equity plan and any other secondary source of information, please speak up and let Fleet's CEO know ASAP. Even if you're wrong, your note will be appreciated. - - -### Deliver annual report for venture line -Within 60 days of the end of the year, follow these steps: -1. Provide Silicon Valley Bank (SVB) with our balance sheet and profit and loss statement (P&L, sometimes called a cashflow statement) for the past twelve months. -2. Provide SVB with our board-approved annual operating budgets and projections (on a quarterly granularity) for the new year. -3. Deliver this as early as possible in case they have questions. - - -### Process a new vendor invoice -Fleet pays its vendors in less than 15 business days in most cases. All invoices and tax documents should be submitted to the Business Operations department using the [appropriate Fleet email address (confidential Google Doc)](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). -- After making sure the invoice received from a new vendor is valid, add the new vendor to the recurring expenses section of ["The numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) before paying the invoice. -- If we have not paid this vendor before, make sure we have received the required W-9 or W-8 form from the vendor. **Accounting cannot process a payment without these tax forms for compliance reasons.** - - **US-based vendors** are required to complete a [W-9 form](https://www.irs.gov/pub/irs-pdf/fw9.pdf). - - **Non-US based vendors and individuals** are required to follow these [instructions](https://www.irs.gov/instructions/iw8bene) and provide a completed [W-8BEN-E](https://www.irs.gov/pub/irs-pdf/fw8bene.pdf) form. - - - -### Process a request to cancel a vendor -- Make the cancellation notification in accordance with the contract terms between Fleet and the vendor, typically these notifications are made via email and may have a specific address that notice must be sent to. If the vendor has an autorenew contract with Fleet there will often be a window of time in which Fleet can cancel, if notification is made after this time period Fleet may be obligated to pay for the subsequent year even if we don't use the vendor during the next contract term. -- Once cancelled, update the recurring expenses section of [The Numbers](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) to reflect the cancellation by changing the projected monthly burn in column G to $0 and adding "CANCELLED" in front of the vendor's name in column C. - - -### Review an NDA -We need to review an NDA anytime a vendor, customer or other party wants to: -- Use their own NDA rather than Fleet's standard NDA, or -- "Redline" (modify) Fleet's NDA by removing, adding or altering its terms. - -We should always seek to use Fleet's own NDA first, without alteration. - -When reading an NDA, we want to pay close attention to the following: -- We want to be sure that the confidentiality obligations of the NDA are reciprocal. Fleet and the other party to the agreement should be bound to the same standards of confidentiality toward the handling of each other's confidential information. -- Fleet does not agree to _"do not compete"_ or _"do not solicit clauses"_. An NDA should not contain provisions beyond the scope of an NDA. The two most commonly encountered examples of this are the "do not compete" and "do not solicit" clauses. We want to be free to hire the best people and make the best products, so when reading through an NDA it is important to keep an eye out for language that prohibits Fleet from hiring or soliciting current or former employees of other companies or that prohibit Fleet from independently developing products that compete with another company's products. Using the `cmd + f` function to search for "solici", "compet" and "hir" and reading through the results is a helpful method to quickly scan for these clauses. -- Look for any language that discusses a transfer of property rights. Rarely, you may find a clause snuck into an agreement that discusses the transfer of intellectual property rights. _We want to avoid any situation where Fleet transfers its intellectual property to another party as part of an NDA_. -- Should you find any clauses in steps 2 or 3 that are beyond the scope of protecting both party's confidential information in a customer NDA or an altered version of Fleet's NDA, reject this language and communicate that Fleet cannot agree to those terms. -- Any concerns or uncertainty over _any_ provisions in an NDA should be brought to Nathanael Holliday in BizOps, who will consult legal counsel if necessary to resolve any concerns. - -### Review a vendor agreement -When reviewing contracts from a vendor, Fleet is concerned about the following: -- If there are confidentiality provisions in the agreement in place of a stand-alone NDA, verify the confidentiality provisions are appropriate and protect Fleet when sensitive data is involved that isn't otherwise available to the public. -- We want to make sure there are no _do not solicit_ or _do not compete_ clauses in the contract. To aid in this search, we double check by using the cmd + f function and searching for "solici", "compet" and "hir" and then looking through the results to be sure that nothing prohibits Fleet from independently developing competing products or from hiring personnel with ties to the vendor. -- We want to make sure that contracts can be terminated relatively easily and be aware of what the process is for terminating them, avoiding commitments over 12 months in length. -- We want to make sure the payment terms work for us (i.e. being able to pay via wire transfer, credit card or bill.com) and that the price in any contract or order form is what we have agreed to. While almost never malicious, mistakes often occur in the steps between agreeing on a price, negotiating a contract, and receiving an invoice. We want to be sure at every step that the dollar amount and service provided is consistent with what has been negotiated and agreed upon. -- Remember, once we have signed the agreement - we're stuck with it. If any clause in the agreement appears strange or gives you pause or concern, it is better to seek clarification than to commit to something that might be detrimental to Fleet. Contracts are fairly standardized, and you'll quickly learn what is normal and what feels out of place. Unusual clauses or wording that seems out of the ordinary should get a second set of eyes just to be sure, do not hesitate to reach out to Nathanael Holliday with questions, who will reach out to legal counsel as necessary. - -### Review an order form -- We should always check order forms for additional terms that go beyond the scope of the order form (caps on price increases, for example). -- Be sure the order form includes contact information + billing address and information so that Fleet knows how and who to invoice for payment. -- Verify that the payment terms are correct and matches what's in the agreement. This is a frequent common mistake as companies usually have default payment terms and overlook changing them to match atypical payment terms. -- Make sure the effective term of the order matches what was agreed upon (usually a one year term) and that the order form includes the correct number of hosts and whether or not it should contain professional services (usually, it does not). -- Check that the amount on the order form reflects what Fleet agreed to, as this is the amount that the customer will expect to be invoiced for. -- Lastly, double check one more time to make sure there are no sneaky, unusual terms snuck in at the bottom of an order form or stashed away in fine print. Common things that are included in order forms and not always communicated to Fleet are caps on price increases upon renewal, new SLAs, or a product roadmap or milestones we may not have agreed upon. Any clauses on an order form that appear beyond the scope of simply elaborating on the services being provided, the purchase cost, the contract that the purchase is being made under, how Fleet will bill and how the customer will pay deserves a careful look. Reach out to Nathanael Holliday in BizOps with concerns. - -### Review a non-standard subscription agreement -We want to use our standard terms whenever possible with our customers, but it is common that customers want to use their own agreement or redline (modify) Fleet's terms. -When reviewing subscription agreements on customer paper or when a customer has made changes to Fleet's terms, we review it using [these guidelines](https://docs.google.com/document/d/1aGgN5It1i3fdsBF37vWSbvukO_gQhy5vCp4fINg191Q/edit?usp=sharing). - - -### Update weekly KPIs -- Create the weekly update issue from the template in ZenHub every Friday and update the [KPIs for BizOps](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) by 5pm US central time. -- Check the KPI sheet at 5pm US central time to ensure all departments have updated their KPIs on time. If any departments are delinquent, notify the department head and let the [Apprentice](https://fleetdm.com/handbook/digital-experience#team) know so they can put it on the agenda for their next one-on-one with the CEO. - - -## Rituals - -The following table lists this department's rituals, frequency, and Directly Responsible Individual (DRI). - - - - - -#### Stubs -The following stubs are included only to make links backward compatible. - -##### Vetty -Please see [hanbook/business-operations#access-a-background-check](https://www.fleetdm.com/handbook/business-operations#access-a-background-check). - -##### Role-specific licenses -Please see [hanbook/business-operations#grant-role-specific-license-to-a-team member](https://www.fleetdm.com/handbook/business-operations#grant-role-specific-license-to-a-team-member). - -##### Recurring expenses -##### Tools we use -Please see [hanbook/business-operations#grant-role-specific-license-to-a-team member](https://www.fleetdm.com/handbook/business-operations#reconcile-monthly-recurring-expenses). - -##### Secure company-issued equipment for a team member -Please see [handbook/engineering#secure-company-issued-equipment-for-a-team-member](https://www.fleetdm.com/handbook/engineering#secure-company-issued-equipment-for-a-team-member). - -##### Register a domain for Fleet -Please see [handbook/register-a-domain-for-fleet](https://www.fleetdm.com/handbook/engineering#register-a-domain-for-fleet). - -##### Updating personnel details -Please see [handbook/engineering#update-personnel-details](https://www.fleetdm.com/handbook/engineering#update-personnel-details). - -##### Fix a laptop that's not checking in -Please see [handbook/engineering#fix-a-laptop-thats-not-checking-in](https://www.fleetdm.com/handbook/engineering#fix-a-laptop-thats-not-checking-in) - -##### Enroll a macOS host in dogfood -Please see [handbook/engineering#enroll-a-macos-host-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-macos-host-in-dogfood) - -##### Enroll a Windows or Ubuntu Linux device in dogfood -Please see [handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood) - -##### Enroll a ChromeOS device in dogfood -Please see [handbook/engineering#enroll-a-chromeos-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-chromeos-device-in-dogfood) - -##### Lock a macOS host in dogfood using fleetctl CLI tool -Please see [handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool](https://www.fleetdm.com/handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool) - -##### Book an event -Please see [handbook/engineering#book-an-event](https://www.fleetdm.com/handbook/engineering#book-an-event) - -##### Order SWAG -Please see [handbook/engineering#order-swag](https://www.fleetdm.com/handbook/engineering#order-swag) - - - - diff --git a/handbook/company/README.md b/handbook/company/README.md index 03f0ac42a7..037922d3d1 100644 --- a/handbook/company/README.md +++ b/handbook/company/README.md @@ -137,34 +137,19 @@ Fleet added support for [scripting and management capabilities](https://fleetdm. ## Org chart To provide clarity about decision-making, [responsibility](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility), and resources, everyone at Fleet has a manager, and [every manager](https://fleetdm.com/handbook/company/leadership) has direct reports. Fleet's organizational chart is accessible company-wide as a sub-tab in ["🧑‍🚀 Fleeties" (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0). On the other sub-tabs, you can also check out a world map of where everyone is located, hiring stats, and fun facts about each team member. -- 🔦 [Business Operations](https://fleetdm.com/handbook/business-operations): The Business Operations department is directly responsible for people operations, finance + invoicing, tax, compliance, and legal + deal desk. -- 🌦️ [Customer Success](https://fleetdm.com/handbook/customer-success): The customer success department is directly responsible for ensuring that customers and community members of Fleet achieve their desired outcomes with Fleet products and services. -- 🐋 [Sales](https://fleetdm.com/handbook/sales): The Sales department is directly responsible for attaining the revenue goals of Fleet and helping customers deliver on their objectives. -- 🫧 [Demand](https://fleetdm.com/handbook/demand): The Demand department is directly responsible for growing awareness of Fleet and nurturing the community through participation in events, conversations, and other programs. - 🚀 [Engineering](https://fleetdm.com/handbook/engineering): The Engineering department at Fleet is directly responsible for writing and maintaining the code for Fleet's core product, as well as Fleet's Information technology (IT) infrastucture. - 🦢 [Product Design](https://fleetdm.com/handbook/product-design): The Product Design department is directly responsible for defining and prioritizing the changes made to the core product, Fleet API, and reference documentation. -- 🌐 [Digital Experience](https://fleetdm.com/handbook/digital-experience): The Digital Experience department is directly responsible for the framework, content design, and technology behind Fleet's remote work culture and overall brand experience, including fleetdm.com, the handbook, issue templates, UI style guides, consistent brandfronts, internal tooling, Zapier flows, Docusign templates, key spreadsheets, and project management processes. +- 🌦️ [Customer Success](https://fleetdm.com/handbook/customer-success): The customer success department is directly responsible for ensuring that customers and community members of Fleet achieve their desired outcomes with Fleet products and services. +- 🫧 [Demand](https://fleetdm.com/handbook/demand): The Demand department is directly responsible for growing awareness of Fleet and nurturing the community through participation in events, conversations, and other programs. +- 💸 [Finance](https://fleetdm.com/handbook/finance): The Finance department is directly responsible for accounts receivable including invoicing, accounts payable including commision calculations, exspense reporting including Brex memos and maintaining accurate spend projections in "🧮The numbers", sales taxes, payroll taxes, corporate income/franchise taxes, and financial operations including bank accounts and cash flow management. +- 🐋 [Sales](https://fleetdm.com/handbook/sales): The Sales department is directly responsible for attaining the revenue goals of Fleet and helping customers deliver on their objectives. +- 🌐 [Digital Experience](https://fleetdm.com/handbook/digital-experience): The Digital Experience department is directly responsible for the culture, training, framework, content design, and technology behind Fleet's remote work culture, including fleetdm.com, the handbook, issue templates, UI style guides, internal tooling, Zapier flows, Docusign templates, key spreadsheets, contracts, compliance, receiving and responding to legal notices, SOC2, deal desk, project management processes, human resources, benefits, opening positions, compensation planning, onboarding, and offboarding. + ## Advisors While most improvements at Fleet are driven by informal conversations with customers and open-source contributors, the company also has a few dozen advisors and investors, including [Sid](https://about.gitlab.com/blog/2022/10/14/one-third-of-what-we-learned-about-ipos-in-taking-gitlab-public/) [Sijbrandij](https://about.gitlab.com/handbook/ceo/#sijbrandij-pronunciation-hint) _(GitLab)_, [Dylan Field](https://en.wikipedia.org/wiki/Dylan_Field) _(Figma)_, [Mike Arpaia](https://www.youtube.com/watch?v=zfCak2UIOD8) _(osquery)_, [Alexandr Wang](https://www.businessofbusiness.com/articles/scale-ai-machine-learning-startup-alexandr-wang/) _(Scale AI)_, [Sanjay](https://www.zdnet.com/article/vmware-buys-airwatch-for-1-54-billion-acquires-mobility-strategy/) [Poonen](https://www.businessinsider.com/vmware-carbon-black-acquisition-sanjay-poonen-cybersecurity-2019-10?op=1) _(VMware, Cohesity)_, and [other smart people who are eager to help](https://docs.google.com/spreadsheets/d/15knBE2-PrQ1Ad-QcIk0mxCN-xFsATKK9hcifqrm0qFQ/edit). If you have a question for one of them, Fleet's CEO is happy to introduce you. ([Just ask](https://fleetdm.com/handbook/company/leadership#contact-the-ceo).) - diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 0c3eed5af7..c83524ee66 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -38,8 +38,8 @@ We track competitors' capabilities and adjacent (or commonly integrated) product | Social media | _See [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team)_ | Blog | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ | Information technology (IT) | _See [🚀 Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team)_ -| Payroll, bookkeeping, AR/AP | _See [🔦 Head of Business Operations](https://fleetdm.com/handbook/customer-success#team)_ -| Legal contracts | _See [🔦 Business Operations team](https://fleetdm.com/handbook/customer-success#team)_ +| Payroll, bookkeeping, AR/AP | _See [💸 Head of Finance](https://fleetdm.com/handbook/finance#team)_ +| Legal contracts | _See [🌐 Digital Experience team](https://fleetdm.com/handbook/digital-experience#team)_ | Customer renewals | _See [🌦️ VP of Customer Success](https://fleetdm.com/handbook/customer-success#team)_ | Customer deployments | _See [🌦️ Infrastructure Engineer](https://fleetdm.com/handbook/customer-success#team)_ | Customer support | _See [🌦️ Customer Success team](https://fleetdm.com/handbook/customer-success#team)_ @@ -53,11 +53,26 @@ We track competitors' capabilities and adjacent (or commonly integrated) product | Product introduction docs | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ | Product deployment docs | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Product usage docs | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ -| Product reference docs | _See [🦢 Noah Talerman](https://fleetdm.com/handbook/product-design#team)_ +| Product reference docs | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ | What goes in a release | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Engineering output and architecture | _See [🚀 Chief Technology Officer](https://fleetdm.com/handbook/engineering#team)_ | Product development | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ +## Tech stack admins + +| Role | Google Workspace | Slack | GitHub | Gusto | Pilot | Plane | 1Password | +|:-----|-----------------:|------:|-------:|------:|------:|------:|----------:| +| CEO | ✅ Super admin | ✅ Primary workspace owner | ✅ Owner | ✅ Primary admin | ✅ Owner |✅ Owner | ✅ Owner | +| CTO | ❌ | ❌ | ✅ Owner | ❌ | ❌ | ✅ Admin | ❌ | +| Head of Finance | ❌ | ❌ | ❌ | ✅ Admin | ✅ Admin | ✅ Admin | ❌ | +| Finance Engineer | ❌ | ❌ | ❌ | ✅ Admin | ✅ Admin |✅ Admin | ❌ | +| Head of Digital Experience | ✅ Super admin | ✅ Owner | ✅ Owner| ✅ Admin | ❌ | ✅ Admin | ✅ Admin | +| Apprentice | ✅ Super admin| ✅ Owner | ✅ Owner | ✅ Admin | ❌ | ✅ Admin | ✅ Admin | +| Digital Experience Engineer | ✅ Super admin | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ✅ Admin | +| Head of Product Design | ❌ | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ❌ | +| VP of CX | ❌ | ✅ Owner | ❌ | ❌ | ❌ | ❌ | ❌ | +| CX Sr. Suppoert Engineer | ❌ | ✅ Admin | ❌ | ❌ | ❌ | ❌ | ❌ | +| Pilot bookkeeper | ❌ | ❌ | ❌ | ✅ Admin | ❌ | ✅ Admin | ❌ | ### Docs @@ -92,7 +107,7 @@ Any change to fleetdm.com follows the same process as [making changes](https://f Before committing anything to code, we create wireframes (referred to as ["drafting"](https://fleetdm.com/handbook/company/product-groups#making-changes)) to illustrate all changes that affect the layout and structure of the user interface, design, or APIs of fleetdm.com. See [Why do we use a wireframe first approach](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach) for more information. -The [Digital Experience team](https://fleetdm.com/handbook/digital-experience#team) holds regular design review sessions to evaluate, revise, and approve wireframes before moving into production. Design review sessions are hosted by the [Head of Design](https://calendar.google.com/calendar/u/0?cid=bXRob21hc0BmbGVldGRtLmNvbQ) and typically take place daily, late afternoon (CST). Anyone is welcome to join. +The [Digital Experience team](https://fleetdm.com/handbook/digital-experience#team) holds regular design review sessions to evaluate, revise, and approve wireframes before moving into production. Design review sessions are hosted by the [Head of Design](https://calendar.google.com/calendar/u/0?cid=bXRob21hc0BmbGVldGRtLmNvbQ) and typically take place daily, late afternoon (CT). Anyone is welcome to join. ## Marketing programs @@ -133,11 +148,10 @@ It's important for Fleet to engage at [events](https://docs.google.com/spreadshe #### Event lead follow-up -Eventgoers expect a timely follow-up from Fleet based on the conversations that they had at the event. +Eventgoers expect a timely [follow-up from Fleet](https://fleetdm.com/handbook/demand#upload-contacts-to-salesforce-after-an-event) based on the conversations that they had at the event. -1. Once a list of badge scans is available, Fleeties that attended the event are to add any follow up notes that note buying situation, amount of endpoints, level of interest, and general talking points. -2. Within 3 business days of returning from the event, attendees will set up a debrief meeting with the demand team to discuss follow-up. -3. Demand will determine appropriate follow-up to each potential lead, and sales will be notified of actions needed immediately following. +1. Once a list of badge scans is available, Fleeties who attended the event are to add any follow-up notes, including primary buying situation, amount of endpoints, level of interest, and general talking points. +2. Within three business days of returning from the event, attendees will set up a debrief meeting with the demand team to discuss follow-up and provide the list of badge scans uploaded to the ["Events" folder in Google Drive](https://drive.google.com/drive/u/0/folders/1uXf95V6CHKHnqxRc9iQr0a0FnTZk3bXR). ### Podcast @@ -177,7 +191,7 @@ Fleet uses YouTube to help keep the community up-to-date and informed. These vid When scheduling external meetings, provide external participants with a [Calendly](https://calendly.com) link to schedule with the relevant internal participants. If you -need a Calendly account, reach out to `#g-business-operations` via Slack. +need a Calendly account, reach out to `#g-digital-experience` via Slack. ### Internal meeting scheduling @@ -285,7 +299,7 @@ In some instances, you may need to record a call locally (i.e. save the recordin Fleet uses these levels to standardize a commitment to minimal esotericism across the company. - **Public:** _Share with anyone, anywhere in the world_ - **Confidential:** _Share only with team members who've signed an NDA, consulting agreement, or employment agreement_ -- **Classified:** _Share only with founders of Fleet, business operations, and/or the people involved. e.g., US social security numbers during hiring_ +- **Classified:** _Share only with the CEO, Head of Digital Experience, and/or the people involved. e.g., US social security numbers during hiring_ ### Document titles @@ -294,8 +308,8 @@ Fleet uses these levels to standardize a commitment to minimal esotericism acros - **"Public":** _(Available to public)_ - _(Confidential - for Fleet eyes only)_ - **"¶":** _(E-group - Direct reports the the CEO)_ -- **"¶¶":** _(Classified - CEO, Apprentice, and BizOps)_ -- **"¶¶¶":** _(CEO, Apprentice to the CEO, and board members)_ +- **"¶¶":** _(Classified - CEO, Head of Digital Experience, and Apprentice)_ + ## Google Drive @@ -346,7 +360,7 @@ We use these prefixes to organize the Fleet Slack: ### Create a GitHub issue from a Slack thread -If you need to track content from a Slack channel (ie. #g-sales), you can automatically generate a github issue by selecting the `create-github-issue` emoji on the thread. This will automatically create an issue tagged with the #g-business-operations label. If you need the issue logged against a specific board, ensure that you have updated the label during issue creation. +If you need to track content from a Slack channel (ie. #g-sales), you can automatically generate a github issue by selecting the `create-github-issue` emoji on the thread. This will automatically create an issue tagged with the GitHub label that corisponds with the Slack channel. If you need the issue logged against a specific board, ensure that you have updated the label during issue creation. image @@ -454,7 +468,7 @@ Any Fleetie can follow the process below to add a priority label to an issue. Estimation points represent the effort required to complete a task. After accessing wireframes, we typically play planning poker, a gamified estimation technique, to determine the necessary story point value. -We use the following story points to estimate website tasks: +We use the following story points to estimate tasks: | Story point | Time | |:---|:--------------| @@ -465,11 +479,13 @@ We use the following story points to estimate website tasks: | 8 | Up to a week | | 13 | 1 to 2 weeks | +> Larger projects are estimated in a way that can sometimes look disproportionate to account for edge cases that weren't caught during planning. This helps us develop [iteratively](https://fleetdm.com/handbook/company#results) and deliver bite-sized functionality on more predictable time scales. + ### Making a pull request Our handbook and docs pages are written in Markdown and are editable from our website (via GitHub). Follow the instructions below to propose an edit to the handbook or docs. -1. Click the _"Edit page"_ button from the relevant handbook or docs page on [fleetdm.com](https://www.fleetdm.com) (this will take you to the GitHub browser). +1. Click the _"Edit page"_ button (top right of the page) from the relevant handbook or docs page on [fleetdm.com](https://www.fleetdm.com) (this will take you to the GitHub browser). 2. Make your suggested edits in the GitHub. 3. Click _"Commit changes...."_ 4. Give your proposed change a title or _["Commit message"](https://about.gitlab.com/topics/version-control/version-control-best-practices/#write-descriptive-commit-messages)_ and optional _"Extended description"_ (good commit messages help page maintainers quickly understand the proposed changes). @@ -597,7 +613,7 @@ For more developed thoughts about __spending guidelines and limits__, please rea #### Non-travel purchases that exceed a Brex cardholder's limit -For non-travel purchases that would require an increase in the Brex cardholder's limit ($2,000 by default), please [make a request](https://fleetdm.com/handbook/business-operations#contact-us) with following information: +For non-travel purchases that would require an increase in the Brex cardholder's limit ($2,000 by default), please [make a request](https://fleetdm.com/handbook/digital-experience#contact-us) with following information: - The nature of the purchase (i.e. SaaS subscription and what it's used for) - The cost of the purchase and whether it is a fixed or variable (i.e. use-based) cost. - Whether it is a one time purchase or a recurring purchase and at what frequency the purchase will re-occur (annually, monthly, etc.) @@ -620,7 +636,7 @@ When procuring SaaS tools and services, analyze the purchase of these subscripti #### Reimbursements -Fleet does not reimburse expenses. We provide all of our team members with Brex cards for making purchases for the company. For company expenses, **use your Brex card.** If there was an extreme accident, [get help](https://fleetdm.com/handbook/business-operations#contact-us). +Fleet does not reimburse expenses. We provide all of our team members with Brex cards for making purchases for the company. For company expenses, **use your Brex card.** If there was an extreme accident, [get help](https://fleetdm.com/handbook/digital-experience#contact-us). - Be creative. If an AirBnb is the most efficient way to house the team, then do that. If separate hotel rooms are more efficient, then do that. - If the stay is longer than 4 nights and an Airbnb with a washing machine is not available, then dry cleaning can be purchased with your Brex card. -- If you need to meet with a large group that won't fit in your hotel room or Airbnb (e.g. more than 5 people), [contact Business Operations](https://fleetdm.com/handbook/business-operations#contact-us) for their help approving and booking additional event space. +- If you need to meet with a large group that won't fit in your hotel room or Airbnb (e.g. more than 5 people), [contact Digital Experience](https://fleetdm.com/handbook/digital-experience#contact-us) for their help approving and booking additional event space. ### Spending company money while traveling When attending a conference or traveling for Fleet, keep the following in mind: - **No reimbursements:** Use your company Brex card. Reimbursements are time consuming, so Fleet does not do reimbursements for spending on personal credit cards. -- **Food:** Be efficient and use your own credit card when it makes sense. There is a $100 allowance per day for your own personal food and beverage on your company Brex card. _(There are many good reasons to make exceptions to this allowance, such as dinners with customers. Before proceeding, please [request approval from the Head of Business Operations](https://fleetdm.com/handbook/business-operations#contact-us) to avoid complexities._ +- **Food:** Be efficient and use your own credit card when it makes sense. There is a $100 allowance per day for your own personal food and beverage on your company Brex card. _(There are many good reasons to make exceptions to this allowance, such as dinners with customers. - **Tipping:** Tipping norms vary by culture. How you tip when representing the company reflects on Fleet's brand. When traveling in the United States and using your company Brex card, prepare to tip between 18-20% at restaurants. For rideshare, takeout, delivery, and other situations where tipping comes up, tip between 10-20%. - **Personal credit card:** Please use your personal credit card for hotel incidentals, personal consumables, movies, mini bars, and entertainment. These expenses _will not_ be reimbursed. - **Company credit card:** We recommend you order a physical Brex card if you do not have one before traveling. -- **Credit card limit increases:** The monthly limit on your Brex card may need to be increased temporarily as necessary to accommodate the increased spending associated with the conference, such as [booking your own travel](https://fleetdm.com/handbook/company/communications#flights). You can [request that here](https://fleetdm.com/handbook/business-operations#contact-us) by providing the following information: +- **Credit card limit increases:** The monthly limit on your Brex card may need to be increased temporarily as necessary to accommodate the increased spending associated with the conference, such as [booking your own travel](https://fleetdm.com/handbook/company/communications#flights). You can [request that here](https://fleetdm.com/handbook/digital-experience#contact-us) by providing the following information: - The start and end dates for your trip. - The [price of your flight](https://fleetdm.com/handbook/company/communications#flights) - The [price of your hotel or Airbnb](https://fletdm.com/handbook/comopany/communications#lodging) per night @@ -717,10 +733,10 @@ Before spending any money on an offsite, inviting people, booking travel, or oth - a lean budget (including links and street address of lodging and event spaces, estimated airfare, and spending for other food or accomodations) - a detailed agenda of how time will be spent - **Bring to e-group:** Ask your manager to bring your plan for the offsite to the next weekly e-group meeting for feedback, edits, and CEO approval. - - **Iteration:** The E-group will discuss live, make edits, and may ask you to provide additional information or changes and return the following week for another pass. + - **Iteration:** The E-group will discuss live, make edits, and establish a DRI for the offsite. -After the plan for the offsite is approved at the e-group meeting (including participants, detailed agenda, and final budget): - - The Head of Business Operations will confirm dates with all approved participants, then book all [lodging](#lodging) and coordinate any additional event space. (Participants [book their own flights](https://fleetdm.com/handbook/company/communications#flights).) +After the plan for the offsite is approved at the e-group meeting (including recommended participants, goals, and budget): + - The DRI will confirm dates with all approved participants, then book all [lodging](#lodging) and coordinate any additional event space. (Participants [book their own flights](https://fleetdm.com/handbook/company/communications#flights).) - _**Note:** If the plan needs to change again, after it is approved, [ask Apprentice to the CEO for help](https://fleetdm.com/handbook/digital-experience#contact-us)._ @@ -740,7 +756,7 @@ You can learn more about how Fleet approaches security in the [security handbook ## Vendor questionnaires -In responding to security questionnaires, Fleet endeavors to provide full transparency via our [security policies](https://fleetdm.com/handbook/security/security-policies#security-policies), [trust](https://trust.fleetdm.com/), and [application security](https://fleetdm.com/handbook/business-operations/application-security) documentation. In addition to this documentation, please refer to [the vendor questionnaires page](https://fleetdm.com/handbook/business-operations/vendor-questionnaires). [Contact the Sales department](https://fleetdm.com/handbook/sales#contact-us) to address any pending questionnaires. +In responding to security questionnaires, Fleet endeavors to provide full transparency via our [security policies](https://fleetdm.com/handbook/digital-experience/security-policies#security-policies), [trust](https://trust.fleetdm.com/), and [application security](https://fleetdm.com/handbook/digital-experience/application-security) documentation. In addition to this documentation, please refer to [the vendor questionnaires page](https://fleetdm.com/handbook/digital-experience/vendor-questionnaires). [Contact the Sales department](https://fleetdm.com/handbook/sales#contact-us) to address any pending questionnaires. ## Getting a contract signed @@ -764,7 +780,7 @@ Please use [Fleet's billing email address](https://fleetdm.com/handbook/company/ To get a contract reviewed, upload the agreement to [Google Drive](https://drive.google.com/drive/folders/1G1JTpFxhKZZzmn2L2RppohCX5Bv_CQ9c). -Complete the [contract review issue template in GitHub](https://fleetdm.com/handbook/business-operations#contact-us), being sure to include the link to the document you uploaded and using the Calendly link in the issue template to schedule time to discuss the agreement with Nathan Holliday (allowing for sufficient time for him to have reviewed the contract prior to the call). +Complete the [contract review issue template in GitHub](https://github.com/fleetdm/confidential/issues/new?assignees=hollidayn&labels=%23g-digital-experience&projects=&template=contract-review.md&title=Review%3A++%F0%9F%96%8B%EF%B8%8F+__________________________), being sure to include the link to the document you uploaded and using the Calendly link in the issue template to schedule time to discuss the agreement with Nathan Holliday (allowing for sufficient time for him to have reviewed the contract prior to the call). Follow up comments should be made in the GitHub issue and in the document itself so it is all in the same place. @@ -776,7 +792,7 @@ If an agreement requires an additional review during the negotiation process, th When no further review or action is required for an agreement and the document is ready to be signed, the requestor is then responsible for routing the document for signature. -> **Note:** Please submit other legal questions and requests to [Business Operations department](https://fleetdm.com/handbook/business-operations#contact-us). +> **Note:** Please submit other legal questions and requests to [Digital Experience](https://fleetdm.com/handbook/digital-experience#contact-us). ## Trust @@ -794,7 +810,7 @@ Here are a few different entry points for a tour of Fleet's security policies an 3. [Account recovery process](https://fleetdm.com/handbook/security#account-recovery-process) 4. [Personal mobile devices](https://fleetdm.com/handbook/security#personal-mobile-devices) 5. [Hardware security keys](https://fleetdm.com/handbook/security#hardware-security-keys) -6. More details about internal security processes at Fleet are located on [the Security page](https://fleetdm.com/handbook/business-operations/security). +6. More details about internal security processes at Fleet are located on [the Security page](https://fleetdm.com/handbook/digital-experience/security). ## Benefits @@ -848,7 +864,7 @@ When you need to take time off, follow this process: ### Coworking -Your Brex card may be used for up to $500 USD per month in coworking costs. Please get prior approval by making a [custom request to the business operations team](https://fleetdm.com/handbook/business-operations#contact-us). +Your Brex card may be used for up to $500 USD per month in coworking costs. Please get prior approval from the [Digital Experience team](https://fleetdm.com/handbook/digital-experience#contact-us). ## Compensation @@ -870,12 +886,12 @@ We're happy you've ventured a trip around the sun with Fleet- let's celebrate! T ### Compensation changes -Fleet evaluates and (if relevant) updates compensation decisions yearly, shortly after the anniversary of a team member's start date. The Head of BizOps is responsible for the process to [update compensation](https://fleetdm.com/handbook/business-operations#updating-compensation) +Fleet evaluates and (if relevant) updates compensation decisions yearly, shortly after the anniversary of a team member's start date. The Head of Digital Experience is responsible for the process to [update compensation](https://fleetdm.com/handbook/digital-experience#updating-compensation) ### Relocating -When Fleeties relocate, there are vendors that need to be notified of the change. Before relocating, please [let the company know in advance](https://fleetdm.com/handbook/business-operations#contact-us) by following the directions listed in the relevant issue template ("Moving"). +When Fleeties relocate, there are vendors that need to be notified of the change. Before relocating, please [let the company know in advance](https://fleetdm.com/handbook/digital-experience#contact-us) by following the directions listed in the relevant issue template ("Moving"). ## Team member onboarding @@ -908,7 +924,7 @@ We want to make sure that the new team member will be able to complete every tas We believe in taking onboarding and training seriously and that the onboarding template is an essential source of truth and good use of time for every single new hire. If managers see a step that they don't feel is necessary, they should make a pull request to the [onboarding template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/onboarding.md). Expectations during onboarding: -- Onboarding time (all checkboxes checked) is a KPI for the business operations team. Our goal is 14 days or less. +- Onboarding time (all checkboxes checked) is a KPI for the Digital Experience team. Our goal is 14 days or less. - The first 3 weekdays (excluding days off) for **every new team member** at Fleet is reserved for completing onboarding tasks from the checkboxes in their onboarding issue. New team members **should not work on anything else during this time**, whether or not other tasks are stacking up or assigned. It is OK, expected, and appreciated for new team members to **remind their manager and colleagues** of this [important](https://fleetdm.com/handbook/company/why-this-way#why-the-emphasis-on-training) responsibility. - Even after the first 3 days, during the rest of their first 2 weeks, completing onboarding tasks on time is a new team member's [highest priority](https://fleetdm.com/handbook/company/why-this-way#why-the-emphasis-on-training). @@ -940,6 +956,7 @@ During their first week at Fleet, every new team member schedules a contributor - make sure you can succeed with submitting a PR with the GitHub web editor, modifying docs or handbook, and working with Markdown. - talk about Google calendar. - give you a quick tour of the Fleet Google Drive folder. +- make sure new team members understand the expectations of, and [how to prepare](https://fleetdm.com/handbook/company/leadership#prepare-for-the-program) for, the [CEO shadow program](https://fleetdm.com/handbook/company/leadership#ceo-shadow-program). @@ -1000,13 +1017,13 @@ Fleet provides laptops, YubiKey security keys, and software licenses for core te ### Requesting new equipment -As soon as an offer is accepted, Business Operations will reach out to the new team member to start this process and will work with the new team member to get their equipment requested and shipped to them on time. From time to time, team members need to purchase additional equipment in the interest of the company. +As soon as an offer is accepted, Digital Experience will reach out to the new team member to start this process and will work with the new team member to get their equipment requested and shipped to them on time. From time to time, team members need to purchase additional equipment in the interest of the company. If you are in need of additional equipment for any reason, [open an IT support request](https://github.com/fleetdm/confidential/issues/new?assignees=spokanemac&labels=%3Ahelp-it&projects=&template=request-it-support.md&title=%F0%9F%92%BB+Request+IT+support). When possible, Fleet will pull from its warehouse of existing assets before spending [more money on new equipment](https://fleetdm.com/handbook/company/why-this-way#why-spend-less). - **Tracking equipment:** When a device has been purchased, it's added to the [spreadsheet of company equipment](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) where we keep track of devices and equipment, purchased by Fleet. When you receive your new computer, complete the entry by adding a description, model, and serial number to the spreadsheet. -- **Returning equipment:** Apple computers with remaining AppleCare Protection Plans should be reprovisioned to other Fleeties who may have older or less-capable computers. Equipment should be returned once offboarded for reprovisioning. Coordinate offboarding and return with the Head of Business Operations. Please return all equipment to the Fleet IT warehouse using Fleet's FedEx account (address and account # in 1Password). +- **Returning equipment:** Apple computers with remaining AppleCare Protection Plans should be reprovisioned to other Fleeties who may have older or less-capable computers. Equipment should be returned once offboarded for reprovisioning. Coordinate offboarding and return with the Head of Digital Experience. Please return all equipment to the Fleet IT warehouse using Fleet's FedEx account (address and account # in 1Password). - **Equipment retention and replacement:** Older equipment results in lost productivity of Fleeties and should be considered for replacement. Replacement candidates are computers that are no longer under an AppleCare+ Protection Plan (or another warranty plan), are >3 years from the [discontinued date](https://everymac.com/systems/apple/macbook_pro/index-macbookpro.html#specs), or when the "Battery condition" status in Fleet is less than "Normal". The old equipment should be evaluated for return or retention as a test environment. @@ -1042,7 +1059,7 @@ Learn how to communicate as Fleet with guidelines for tone of voice, our approac ### What would Mister Rogers say? -[*Mister Rogers’ Neighborhood*](https://en.wikipedia.org/wiki/Mister_Rogers%27_Neighborhood) was one of the longest-running children’s TV series. That’s thanks to [Fred Rogers](https://en.wikipedia.org/wiki/Fred_Rogers)’ communication skills. He knew kids heard things differently than adults. So, he checked every line to avoid confusion and encourage positivity. +[*Mister Rogers’ Neighborhood*](https://en.wikipedia.org/wiki/Mister_Rogers%27_Neighborhood) was one of the longest-running children’s T.V. series. That’s thanks to [Fred Rogers](https://en.wikipedia.org/wiki/Fred_Rogers)’ communication skills. He knew kids heard things differently than adults. So, he checked every line to avoid confusion and encourage positivity. Our audience is a little older. But just like the show, Mister Rogers’ method is appropriate for all ages. Here are some steps you can take to communicate like Mister Rogers: @@ -1116,21 +1133,50 @@ As we use sentence case, only the first word is capitalized. But, if a word woul - When talking about a users' computer, we prefer to use "device" over _endpoint._ Devices in this context can be a physical device or virtual instance that connect to and exchange information with a computer network. Examples of devices include mobile devices, desktop computers, laptop computers, virtual machines, and servers. -### Headings +### Headings and titles -Headings help readers quickly scan content to find what they need and guide readers through your writing. Organize page content using clear headings specific to the topic they describe. +Headings and titles should give an accurate idea of a topic's content and help guide readers through your writing so they can quickly find what they need. -While our readers are more tech-savvy than most, we can’t expect them to recognize queries by SQL alone. Avoid using code for headings. Instead, say what the code does and include code examples in the body of your document. +#### Static headings + +Use static headings (a `noun` or `noun phrase`) e.g., “Log destinations,” for concept or reference topics. Be as short and specific as possible. + +#### Task-based headings + +Use task-based headings (`verb` + `topic`) e.g., _“Configure a log destination”_ for guides and tutorials where the heading should reveal the task that the reader is trying to achieve. + +#### Avoid _-ing_ verb forms in headings + +Avoid starting a heading with _-ing_ verb form, if possible. + +_-ing_ verb forms are more difficult for non-native English readers to understand, translate inconsistently, and increase character counts in limit spaces, such as in docs navigation. + +| ✅ Recommended | ❌ Not recommended | +| ---------------- | -------------------- | +| “Configure a log destination” | “Configuring a log destination” | + +#### Avoid vague verbs in headings + +Were possible, avoid starting a heading with a vague verb, like “understand,” “learn,” or “Use.” Headings that start with a vague verb can mislead readers by making a topic appear to be task-oriented (a guide) when it is actually reference or conceptual information. + +| ✅ Recommended | ❌ Not recommended | +| ---------------- | -------------------- | +| “Log destinations” | “Understand log destinations.” | + + +#### Avoid code in headings + +While our readers are more tech-savvy than most, we can’t expect them to recognize queries by SQL alone. Avoid using code for headings. Instead, say what the code does and include code examples in the body of your document. That aside, it doesn't render well on the website. + +#### Heading hierarchy + +Use heading tags to structure your content hierarchically. Try to stay within three or four heading levels. Detailed documents may use more, but pages with a simpler structure are easier to read. -Keep headings brief, organized, and in a logical order: - H1: Page title - H2: Main headings - H3: Subheadings - H4: Sub-subheadings -Try to stay within three or four heading levels. Detailed documents may use more, but pages with a simpler structure are easier to read. - - #### Punctuation in headings Fleet headings do not use end punctuation unless the heading is a question: @@ -1217,6 +1263,7 @@ Sometimes numerals seem out of place. If an expression typically spells out the - First impression - Third-party integration - All-in-one platform + Numbers over 3 digits get commas: - 999 - 1,000 @@ -1228,6 +1275,7 @@ Numbers over 3 digits get commas: Use numerals and am or pm without a space in between: - 7am - 7:30pm + Use a hyphen between times to indicate a time period: - 7am–10:30pm @@ -1307,6 +1355,33 @@ Markdown is a simple formatting syntax used to write content on the web. In orde ### Headings +Each heading needs two lines of empty space separating it from the previous section and one line of empty space between the heading and related content. This helps break up blocks of text and is especially important on larger, more detailed pages. Here's an example: + +``` +...previous content. + + +### New heading + +Related content... +``` + + +#### Nested headings + +Wherever possible, avoid creating nested headings. For example: + +``` +## Things + +### Thing 1 + +Hi my name is Thing 1 +``` + + +#### Heading levels + Try to stay within three or four heading levels. Complicated documents may use more, but pages with a simpler structure are easier to read. | Markdown | Rendered heading | |:--------------------|:-----------------------------| @@ -1367,8 +1442,6 @@ line two |
    1. Line one
    2. Line two
    3. Line three
    4. Line four
    | 1. Line one
    2. Line two
    3. Line three
    4. Line four | |
    1. Line one
    1. Indent one
    2. Line two
    3. Line three
    1. Indent one
    2. Indent two
    4. Line four
    | 1. Line one
     1. Indent one
    2. Line two
    3. Line three
     1. Indent one
     2. Indent two
    4. Line four | -Content nested within an ordered list needs to be indented. If the list is not formatted correctly, the number will reset on each list item, as shown in the example below. - **Markdown:** ``` @@ -1387,26 +1460,6 @@ Paragraph about item one 2. Item two -To make sure that ordered lists increment correctly, you can indent the content nested within the list. For example, the same ordered list with indentation: - -**Markdown:** - -``` -1. Item one - - Paragraph about item one - -2. Item two -``` - -**Rendered output:** - -1. Item one - - Paragraph about item one - -2. Item two - #### Unordered lists @@ -1466,6 +1519,11 @@ Use dashes (at least 3) to separate the header, and add colons to align the text |:---|---:|:---:| | Left alignment | Right alignment | Center Alignment | +> When using tables to document API endpoint parameters, we use the following conventions: +> + Document nested objects in their own separate tables. See the [**Modify configuration**](https://fleetdm.com/docs/rest-api/rest-api#modify-configuration) documentation for example formatting. +> + In the **Type** column, use the terms "boolean" (not "bool"), and "array" (not "list"). +> + In the **Description** column for required parameters, begin the description with "**Required.**" + ### Blockquotes @@ -1617,7 +1675,7 @@ This glossary provides definitions to commonly used terms within our space. | **open source** | Software with intentionally public code for the sake of transparency. | | **OS** | (Operating System) Software that provides the groundwork and instructions for a device's basic functions, including application use and controlling peripherals. | | **osquery** | A tool that assembles low-level operating system analytics and monitoring. | -| **out-of-policy device** | A device that is fails any security or vulnerability policy created in Fleet. | +| **out-of-policy device** | A device that fails any security or vulnerability policy created in Fleet. | | **permissions** | Users have different abilities depending on the access level they have. | | **platform** | Any software or hardware for hosting an application, data, or service. | | **policies** | Yes or no questions you can ask using Fleet about your host devices. | @@ -1697,9 +1755,6 @@ Please see 📖[handbook/company/communications#purchase-company-issued-equipmen ##### Buying other new equipment Please see 📖[handbook/company/communications#purchase-company-issued-equipment](https://fleetdm.com/handbook/company/communications#equipment) for above. -##### Purchasing a company-issued device -Please see 📖[handbook/business-operations#secure-company-issued-equipment-for-a-team-member](https://fleetdm.com/handbook/business-operations#secure-company-issued-equipment-for-a-team-member). - ##### Company travel Please see 📖[handbook/company/communications#travel](https://fleetdm.com/handbook/company/communications#travel). diff --git a/handbook/company/handbook.md b/handbook/company/handbook.md index 345437ee0c..f7d416e9b2 100644 --- a/handbook/company/handbook.md +++ b/handbook/company/handbook.md @@ -16,7 +16,7 @@ All done! To contribute a new handbook page: 1. Determine where the new page should live in the handbook. That is, nested under either: a. [the "Company" handbook](https://fleetdm.com/handbook/company), or - b. the handbook for a particular division (Security, Engineering, Product, Sales, Marketing, Business Operations) + b. the handbook for a particular division (Engineering, Product Design, Customer Support, Sales, Demand, Finance, Digital Experience) 2. Locate the appropriate folder for the new page in [the GitHub repository under `handbook/`](https://github.com/fleetdm/fleet/tree/main/handbook). 3. Create a new markdown file (like [one of these](https://github.com/fleetdm/fleet/tree/f90148abad96fccb6c5647a31877fa7e91b5ee57/handbook/digital-experience)). A simple, easy way to do this is by clicking "Add file" on GitHub.com. a. Name your new file the kebab-cased, all lowercase version of your page title, with `.md` at the end. (For example, a page titled "Why this way?" would have the file path: `handbook/company/why-this-way.md`.) diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 6885a8ca97..d89b81639f 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -2,7 +2,7 @@ This page covers the things managers and other leaders at Fleet need to know about running a great company. -image +image @@ -39,7 +39,7 @@ These flaws are listed here publicly for two reasons. The first is so that peopl ## CEO responsibilities -Ultimately, the CEO is responsible for the success or failure of the company. The CEO is the [directly responsible individual (DRI)](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) for pricing, tiers, the business model, signatures on all documents, product marketing (brandfronts, pitchfronts, featurefronts, ICPs, personas, and targeting). +Ultimately, the CEO is responsible for the success or failure of the company. The CEO is the [directly responsible individual (DRI)](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) for pricing, tiers, the business model, human resources, legal counsel, signatures on all documents, and brand & product marketing (brandfronts, pitchfronts, featurefronts, ICPs, personas, and targeting). > **Note:** When the CEO is out of office, CEO responsibilities are either paused, delegated, or coordinated through the [Apprentice to the CEO](https://fleetdm.com/handbook/digital-experience#team) so they can be handled promptly. (It depends on the responsibility and the situation.) @@ -109,7 +109,7 @@ In this meeting, the department leader discusses actual week-over-week progress At Fleet, we collaborate with [core team members](#creating-a-new-position), [consultants](#hiring-a-consultant), [advisors](#adding-an-advisor), and [outside contributors](https://github.com/fleetdm/fleet/graphs/contributors) from the community. -> Are you a new fleetie joining the Business Operations team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). +> Are you a new fleetie joining the Digital Experience team? For Loom recordings demonstrating how to make offers, hire, onboard, and more please see [this classified Google Doc](https://docs.google.com/document/d/1fimxQguPOtK-2YLAVjWRNCYqs5TszAHJslhtT_23Ly0/edit). ### Consultants @@ -131,7 +131,7 @@ Consultants: Consultants [track time using the company's tools](#tracking-hours) and sign [Fleet's consulting agreement](#sending-a-consulting-agreement). -To hire a consultant, [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the business operations team. +To hire a consultant, [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. #### Who ISN'T a consultant? @@ -151,7 +151,7 @@ Consultants aren't required to do any of those things. #### Sending a consulting agreement -To send a consulting agreement, you will need to [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the business operations team. They will then peform the steps needed to bring aboard a new consultant. +To send a consulting agreement, you will need to [submit a new consultant onboarding request](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=new-consultant-onboarding.md&title=New+US%2Finternational+consultant) to the Digital Experience team. They will then peform the steps needed to bring aboard a new consultant. You will be asked to provide the following details: - Consultant's name (or business name) @@ -166,7 +166,7 @@ If the consultant is international, you will also provide: - Consultant's date of birth -> To update a consultant's fee, [submit an issue to BizOps](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-business-operations&projects=&title=Update%20consultant%20fee) with the consultant's name and new hourly rate. +> To update a consultant's fee, [submit an issue to Digital Experience](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&title=Update%20consultant%20fee) with the consultant's name and new hourly rate. image @@ -244,17 +244,18 @@ A completed open position entry should look something like this: - _**Why bother with approvals?** We avoid cancelling or significantly changing a role after opening it. It hurts candidates too much. Instead, get the position approved first, before you start recruiting and interviewing. This gives you a sounding board and avoids misunderstandings._ ### Approving a new position -When review is requested on a proposal to open a new position, the 🐈‍⬛ CEO will complete the following steps when reviewing the pull request: +When review is requested on a proposal to open a new position, the Apprentice to the CEO will complete the following steps when reviewing the pull request: 1. **Consider role and reporting structure:** Confirm the new row in "Fleeties" has a manager, job title, and department, that it doesn't have any corrupted spreadsheet formulas or formatting, and that the start date is set to the first Monday of the next month. 2. **Read job description:** Confirm the job description consists only of changes to "Responsibilities" and "Experience," with an appropriate filename, and that the content looks accurate, is grammatically correct, and is otherwise ready to post in a public job description on fleetdm.com. 3. **Budget compensation:** Ballpark and document compensation research for the role based on - _Add screenshot:_ Scroll to the very bottom of ["¶¶ 💌 Compensation decisions (offer math)"](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit#heading=h.slomq4whmyas) and add a new heading for the role, pattern-matching off of the names of other nearby role headings. Then create written documentation of your research for future reference. The easiest way to do this is to take screenshots of the [relevant benchmarks in Pave](https://pave.com) and paste those screenshots under the new heading. +4. **Decide**: Decide whether to approve this role or to consider it a different time. If approving, then: + - _Update financial model:_ Update ["¶ Financial model"](https://docs.google.com/spreadsheets/d/1tIcuwhmOKolnwNJqQ0zH5rWCqjawYzySDsKTb98RvxI/edit?gid=1184088923#gid=1184088923) - _Update team database:_ Update the row in ["¶¶ 🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) using the benchmarked compensation and share count. - _Salary:_ Enter the salary: If the role has variable compensation, use the role's OTE (on-target earning estimate) as the budgeted salary amount, and leave a note in the "Notes (¶¶)" cell clarifying the role's bonus or commission structure. - _Equity:_ Enter the equity as a number of shares, watching the percentage that is automatically calculated in the next cell. Keep guessing different numbers of shares until you get the derived percentage looking like what you want to see. -4. **Decide**: Decide whether to approve this role or to consider it a different time. If approving, then: - - _Create Slack channel:_ Create a private "#hiring-xxxxxx-YYYY" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. + - _Create Slack channel:_ Create a private "#YYYY-hiring-xxxxxx" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Digital Experience. - _Publish opening:_ Approve and merge the pull request. The job posting will go live within ≤10 minutes. - _Track as approved in "Fleeties":_ In the "Fleeties" spreadsheet, find the row for the new position and update the "Job description" column and replace the URL of the pull request that originally proposed this new position with the URL of the GitHub merge commit when that PR was merged. - _Reply to requestor:_ Post a comment on the pull request, being sure to include a direct link to their live job description on fleetdm.com. (This is the URL where candidates can go to read about the job and apply. For example: `fleetdm.com/handbook/company/product-designer`): @@ -281,7 +282,7 @@ Fleet uses [certain email templates](https://docs.google.com/document/d/1VAMWIH8 ### Hiring restrictions #### Incompatible former employers -Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [BizOps spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). +Fleet maintains a list of companies with whom Fleet has do-not-solicit terms that prevents us from making offers to employees of these companies. The list is in the Do Not Solicit tab of the [Digital Experience spreadsheet](https://docs.google.com/spreadsheets/d/1lp3OugxfPfMjAgQWRi_rbyL_3opILq-duHmlng_pwyo/edit#gid=0). #### Incompatible locations Fleet is unable to hire team members in some countries. See [this internal document](https://docs.google.com/document/d/1jHHJqShIyvlVwzx1C-FB9GC74Di_Rfdgmhpai1SPC0g/edit) for the list. @@ -303,38 +304,54 @@ Department specific interviewing instructions: #### Hiring a new team member This section is about the hiring process a new core team member, or fleetie. -> **_Note:_** _Employment classification isn't what makes someone a fleetie. Some Fleet team members are contractors and others are employees. The distinction between "contractor" and "employee" varies in different geographies, and the appropriate employment classification and agreement for any given team member and the place where they work is determined by Head of Business Operations during the process of making an offer._ +> **_Note:_** _Employment classification isn't what makes someone a fleetie. Some Fleet team members are contractors and others are employees. The distinction between "contractor" and "employee" varies in different geographies, and the appropriate employment classification and agreement for any given team member and the place where they work is determined by Head of Digital Experience during the process of making an offer._ Here are the steps hiring managers follow to get an offer out to a candidate: 1. **Call references:** Before proceeding, make sure you have 2-5+ references. Ask the candidate for at least 2-5+ references and contact each reference in parallel using the instructions in [Fleet's reference check template](https://docs.google.com/document/d/1LMOUkLJlAohuFykdgxTPL0RjAQxWkypzEYP_AT-bUAw/edit?usp=sharing). Be respectful and keep these calls very short. 2. **Add to team database:** Update the [Fleeties](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) doc to accurately reflect the candidate's: - Start date + > _**Tip:** No need to check with the candidate if you haven't already. Just guess. First Mondays tend to make good start dates. When hiring an international employee, Pilot.co recommends starting the hiring process a month before the new employee's start date._ + - First and last name - Preferred pronoun _("them", "her", or "him")_ - LinkedIn URL _(If the fleetie does not have a LinkedIn account, enter `N/A`)_ - Location of candidate -3. **Schedule CEO interview:** [Book a quick chat](https://fleetdm.com/handbook/digital-experience#contact-us) so our CEO can get to know the future Fleetie. (Please take care of all of the previous steps first.) -4. **Confirm intent to offer:** Compile feedback about the candidate into a single document and share that document (the "interview packet") with the Head of Business Operations via Google Drive. _This will be interpreted as a signal that you are ready for them to make an offer to this candidate._ - - _Compile feedback into a single doc:_ Include feedback from interviews, reference checks, and challenge submissions. Include any other notes you can think of offhand, and embed links to any supporting documents that were impactful in your final decision-making, such as portfolios or challenge submissions. - - _Share_ this single document with the Head of Business Operations via email. - - Share only _one, single Google Doc, please_; with a short, formulaic name that's easy to understand in an instant from just an email subject line (e.g. "_Why hire Jane Doe ("Train Conductor") - 2023-03-21_"). - - When the Head of Business Operations receives this doc shared doc in their email with the compiled feedback about the candidate, they will understand that to mean that it is time for Fleet to make an offer to the candidate. + +3. **Compile feedback into a single doc:** In the "interview packet", include feedback from interviews, reference checks, and challenge submissions. Include any other notes you can think of offhand, and embed links to any supporting documents that were impactful in your final decision-making, such as portfolios or challenge submissions. + - Name the doc with a short, formulaic name that's easy to understand in an instant from just an email subject line (e.g. "_Why hire Jane Doe ("Train Conductor") - 2023-03-21_"). + - _Share_ this single document with the CEO. +4. **Request a CEO interview:** Copy the template below, paste it in the hiring Slack channel for the position, and complete all "TODOs" before sending. + + ``` + *CEO interview request:* + Hi @Savannah Friend, the following candidate is ready for a CEO interview. cc: @Sam Pfluger + - Name: TODO + - Position: TODO + - LinkedIn: TODO + - Email: TODO + - Single doc URL: TODO + ``` + +5. **Confirm intent to offer:** Share the single document (the "interview packet") with the Head of Digital Experience via Google Drive. + - _Share_ this single document with the Head of Digital Experience via email. + - When the Head of Digital Experience receives this shared doc in their email with the compiled feedback about the candidate, they will understand that to mean that it is time for Fleet to make an offer to the candidate. + ### Making an offer -After receiving the interview packet, the Head of Business Operations uses the following steps to make an offer: +After receiving the interview packet, the Head of Digital Experience uses the following steps to make an offer: -1. **Prepare the "exit scenarios" spreadsheet:** 🔦 Head of Business Operations [copies the "Exit scenarios (template)"](https://docs.google.com/spreadsheets/d/1k2TzsFYR0QxlD-KGPxuhuvvlJMrCvLPo2z8s8oGChT0/copy) for the candidate, and renames the copy to e.g. "Exit scenarios for Jane Doe". +1. **Prepare the "exit scenarios" spreadsheet:** 🌐 Head of Digital Experience [copies the "Exit scenarios (template)"](https://docs.google.com/spreadsheets/d/1k2TzsFYR0QxlD-KGPxuhuvvlJMrCvLPo2z8s8oGChT0/copy) for the candidate, and renames the copy to e.g. "Exit scenarios for Jane Doe". - _Edit the candidate's copy of the exit scenarios spreadsheet_ to reflect the number of shares in ["🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0), and the spreadsheet will update automatically to reflect their approximate ownership percentage. > _**Note:** Don't play with numbers in the exit scenarios spreadsheet. The revision history is visible to the candidate, and they might misunderstand._ -2. **Prepare offer:** 🔦 Head of Business Operations [copies "Offer email (template)"](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/copy) and renames to e.g. "Offer email for Jane Doe". Edit the candidate's copy of the offer email template doc and fill in the missing information: +2. **Prepare offer:** 🌐 Head of Digital Experience [copies "Offer email (template)"](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/copy) and renames to e.g. "Offer email for Jane Doe". Edit the candidate's copy of the offer email template doc and fill in the missing information: - _Benefits:_ If candidate will work outside the US, [change the "Benefits" bullet](https://docs.google.com/document/d/1zpNN2LWzAj-dVBC8iOg9jLurNlSe7XWKU69j7ntWtbY/edit) to reflect what will be included through Fleet's international payroll provider, depending on the candidate's location. - _Equity:_ Highlight the number of shares with a link to the candidate's custom "exit scenarios" spreadsheet. - _Hand off:_ Share the offer email doc with the [Apprentice to the CEO](https://fleetdm.com/handbook/digital-experience#team). 3. **Draft email:** 🦿 Apprentice to the CEO drafts the offer email in the CEO's inbox, reviews one more time, and then brings it to their next daily meeting for CEO's approval: - To: The candidate's personal email address _(use the email from the CEO interview calendar event)_ - - Cc: Head of Business Operations _(BizOps will participate in the email thread after the offer is accepted)_ + - Cc: Head of Digital Experience - Subject: "Full time?" - Body: _Copy the offer email verbatim from the Google doc into Gmail as the body of the message, formatting and all, then:_ - _Check all links in offer letter for accuracy (e.g. LinkedIn profile of hiring manager, etc.)_ @@ -343,43 +360,43 @@ After receiving the interview packet, the Head of Business Operations uses the f 4. **Send offer:** 🐈‍⬛ CEO reviews and sends the offer to the candidate: - _Grant the candidate "edit" access_ to their "exit scenarios" spreadsheet. - _Send_ the email. +5. **Process the offer response** The Head of Digital Experience will process the offer response by either creating a new ["Teammate pre-onboarding" issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=pre-onboarding.md&title=Pre-onboarding%3A+______________________) and following the steps if the offer is accepted or notifying the stakeholders that the offer was not accepted and we should continue the search. -#### Steps after an offer is accepted -Once the new team member replies and accepts their offer in writing, 🔦 Head of Business Operations follows these steps: -1. **Verify, track, and reply:** Reply to the candidate: - - _Verify the candidate replied with their physical address… or else keep asking._ If they did not reply with their physical address, then we are not done. No offer is "accepted" until we've received a physical address. - - _Review and update the team database_ to be sure everything is accurate, **one last time**. Remember to read the column headers and precisely follow the instructions about how to format the data: - - The new team member's role in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) now includes: - - **Start date** _(The new fleetie's first day, YYYY-MM-DD)_ - - **Location** _(Derive this from the physical address)_ - - **GitHub username** _(Username of 2FA-enabled GitHub account)_ - - **@fleetdm.com email** _(Set this to whatever email you think this person should have)_ - - The new team member's row in ["🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) now includes: - - **OTE** _("On-target earnings", i.e. anticipated total annual cash compensation)_ - - **Equity** _(Stock options)_ - - **"Notes"** _(Track base salary here, as well as a very short explanation of commission or bonus structure.)_ - - **Physical address** _(The full street address of the location where work will typically be performed.)_ - - **Personal email** _(Use the personal email they're replying from, e.g. `@gmail.com`)_ - - **"Offer accepted?"** _(Set this to `TRUE`)_ - - _[Create a "Hiring" issue](https://github.com/fleetdm/confidential/issues/new/choose)_ for the new team member. (This issue will keep track of the hiring tasks for the new team member.) - - _Send a reply_ welcoming the team member to Fleet and letting them know to expect a separate email with next steps for getting the team member's laptop, Yubikeys, and agreement going ASAP so they can start on time. For example: - ``` - \o/ It's official! - - Be on the lookout for an email in a separate thread with next steps for quickly signing the paperwork and getting your company laptop and hardware 2FA keys (Yubikeys), which we recommend setting up ASAP. - - Thanks, and welcome to the team! - - -Joanne - ``` -2. **Ask hiring manager to send rejections:** Post to the `hiring-xxxxx-yyyy` Slack channel to let folks know the offer was accepted, and at-mention the _hiring manager_ to ask them to communicate with [all other interviewees](https://fleetdm.com/handbook/company#empathy) who are still in the running and [let them know that we chose a different person](https://fleetdm.com/handbook/company/leadership#candidate-correspondence-email-templates). - >_**Note:** Send rejection emails quickly, within 1 business day. It only gets harder if you wait._ -3. **Remove open position:** The hiring manager removes the newly-filled position from the fleetdm.com website by [making a pull request](https://fleetdm.com/handbook/company/communications#making-a-pull-request) to delete it from the [open-positions.yml](https://github.com/fleetdm/fleet/blob/main/handbook/company/open-positions.yml) file. -4. **Close Slack channel:** Then archive and close the channel. +#### After an offer is accepted -Now what happens? 🔦 Business Operations will then follow the steps in the "Hiring" issue, which includes reaching out to the new team member within 1 business day from a separate email thread to get additional information as needed, prepare their agreement, add them to the company's payroll system, and get their new laptop and hardware security keys ordered so that everything is ready for them to start on their first day. +The Head of Digital Experience will then follow the steps in the ["Teammate pre-onboarding"](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=pre-onboarding.md&title=Pre-onboarding%3A+______________________) issue, which includes reaching out to the new team member within 1 business day from a separate email thread to get additional information as needed, prepare their agreement, add them to the company's payroll system, and get their new laptop and hardware security keys ordered so that everything is ready for them to start on their first day. +## Create a 30-60-90 day plan + +The hiring manager creates a 30-60-90 day plan outlining key role objectives to be reviewed in 1:1 meetings during the first three months of employment. To create the 30-60-90 day plan, use the prompts in the "Vision" section of the new teammates [1:1 meeting doc (TEMPLATE)](https://docs.google.com/document/d/1IkGQJ4PPU0MyW35Xo8BuvoUPKpStsmcw_nHWt71W2yE/edit#heading=h.uzxntzlyyaou) to ensure continuous support and alignment with company goals. + + +## CEO shadow program + +The CEO shadow program is a three-day temporary assignment (usually completed during onboarding) in which core team members will shadow all relevant meetings on the CEO's calendar. This gives team members an overview of all aspects of the company and provides high context and in turn, high-output contributors. The program also creates opportunities for the CEO to build relationships with team members across the company and to identify [challenges and opportunities](https://fleetdm.com/handbook/company/leadership#ceo-flaws) earlier. + +> **What it's not**: The CEO shadow program is not a performance evaluation or a determinating factor for a promotion or raise. + +As a CEO shadow, you will be attending both internal and external meetings regarding various areas of the company. In order to make the most of your time as a shadow: +- **Be on time**: Your time is priceless, your time as a CEO shadow is short! Join each meeting on time, this limits the distraction for other attendees and gives the CEO the opportunity to introduce you to other folks on the call without interrupting the meeting. +- **Be present**: During meetings outside of your department, it can be easy to find yourself charging ahead on other projects. The interactions you'll have in these meetings and the context you'll gain are important. +- **Be active**: There are many short-term tasks you'll be asked to perform as a shadow. Here are some examples: + - Prepare for, participate in, take notes during, and follow up on meetings. + - Make handbook updates and shadow PR reviews. + - Solve urgent issues. For example, help solve a complaint from a customer or coordinate the response to a technical issue. + - Create and/or complete GitHub issues involving multiple departments at Fleet, and work towards closing or creating pull requests to complete issues. + + +### Prepare for the program + +CEO shadows join all meetings on the [CEO's calendar](https://calendar.google.com/calendar/embed?src=mike%40fleetdm.com&ctz=America%2FChicago) that **do not have** "[no shadows]" appended to the calendar event title. Before beginning your time as a [CEO shadow](https://fleetdm.com/handbook/company/leadership#ceo-shadow-program): +1. Make sure you've read through the [CEO flaws](https://fleetdm.com/handbook/company/leadership#ceo-flaws) to better understand how to communicate with him. +2. Update your Zoom display name to be "CEO shadow | [your name]" (e.g. "CEO shadow | Jayne Doo"). +3. Know which meetings you're expected to join. **You won't be listed as an attendee on any of the CEO's calendar events** to avoid confusion when scheduling meetings with external participants. You're intentionally marked out of office to avoid scheduling conflicts. + +> Please **DO NOT** add yourself as an attendee to any of the CEO's meetings. The CEO regularly meets with prospects and customers in the community, and without the context of the CEO shadow program, an unknown name on the calendar event could be mistaken for a sales tactic. + ## Tracking hours Fleet asks US-based hourly contributors to track hours in Gusto, and contributors outside the US to track hours via Pilot.co. @@ -391,24 +408,21 @@ This applies to anyone who gets paid by the hour, including consultants and hour ## Communicating departures Although it's sad to see someone go, Fleet understands that not everything is meant to be forever [like open-source is](https://fleetdm.com/handbook/company/why-this-way#why-open-source). There are a few steps that the company needs to take to facilitate a departure. -1. **Departing team member's manager:** Inform the Head of Business Operations about the departure via email and cc your manager. The Head of Business Operations will coordinate the team member's last day, offboarding, and exit meeting. -3. **Business Operations**: Will then create and begin completing [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md), to include coordinating team member's last day, offboarding, and exit meeting. - > After finding out about the departure, the Head of Business Operations will post in #g-e to inform the E-group of the team member's departure, asking E-group members to inform any other managers on their teams. +1. **Departing team member's manager:** Inform the Head of Digital Experience about the departure via email and cc your manager. The Head of Digital Experience will coordinate the team member's last day, offboarding, and exit meeting. +3. **Digital Experience**: Will then create and begin completing [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md), to include coordinating team member's last day, offboarding, and exit meeting. + > After finding out about the departure, the Head of Digital Experience will post in #g-e to inform the E-group of the team member's departure, asking E-group members to inform any other managers on their teams. 4. **CEO**: The CEO will make an announcement during the "🌈 Weekly Update" post on Friday in the `#general` channel on Slack. +<<<<<<< HEAD ## Changing someone's position - -From time to time, someone's job title changes. To do this, Business Operations follows these steps: - -1. Change "Fleeties" to reflect the new job title, manager, and/or department. -2. If there is a compensation change, update "Equity plan". Use the first day of a month as the date, and enter this in the corresponding column. -3. If applicable, schedule the change in the appropriate payroll system. (Don't worry about updating job titles in the payroll system.) +From time to time, someone's job title changes. To do this, reach out to [Digital Experience](https://fleetdm.com/handbook/digital-experience). image ## Delivering performance feedback + When it comes to performance feedback, [speak freely](https://fleetdm.com/handbook/company#openness), sooner, and provide an explicit example of the behavior you observed and the impact it had. 1. Deliver negative feedback privately whenever possible, and be constructive not punitive. Celebrate positive feedback publicly. diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 2558d77a30..71bcb2af6b 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -9,6 +9,30 @@ # experience: | # Add markdown content to this field. ################################################ +- jobTitle: 🦢 Product Designer + department: Product Design + hiringManagerName: Noah Talerman + hiringManagerLinkedInUrl: https://www.linkedin.com/in/noah-talerman/ + hiringManagerGithubUsername: noahtalerman + responsibilities: | + - ⏫ Engage with product management, engineering, business stakeholders, and customers to understand initiatives. + - 📣 Design consistent interactions across the Fleet user experience, including API and CLI. + - 🌡️ Drive the refinement process from concept to high-fidelity prototypes. + experience: | + - 💭 3 - 5 years of experience as a Product Designer. + - 💖 Proficient in visual design and wireframing tools (we use Figma). + - 🦉 Articulate the problem to be solved and create a compelling narrative around proposed solutions. + - 📖 Maintain a design system that enables speed for designers, PMs, and engineers. + - 🧑‍🔬 Develop an understanding of developer-first automation workflows, including API and CLI experiences. + - 🧪 Translate user insights into digital experiences that are well-crafted and easy to use. + - 🤝 Collaboration: You work best in a participatory, team-based environment. + - 🚀 Prototype-first: You embrace speed and failure as we iterate towards the right solution. You have hands-on experience in creating low and high-fidelity prototypes. You’re comfortable accepting suboptimal designs in favor of iteration. + - 🧬 Simplicity: You love complex questions and use your work to simplify that complexity for users. + - 🟣 Openness: You are flexible and open to new ideas and ways of working. + - ✍️ Experience designing CLI experiences for developers or willingness to learn. + - ➕ Bonus: YB2B SaaS background + - ➕ Bonus: cybersecurity or IT background + - jobTitle: 🚀 Software Engineer department: Engineering hiringManagerName: Luke Heath @@ -22,7 +46,7 @@ - 🚀 Actively participate in all engineering scrum meetings, including sprint planning, daily standups, sprint demos, sprint retrospectives, and estimation sessions. - 🌟 Contribute to the overall success of the [MDM](https://fleetdm.com/handbook/company/product-groups#mdm-group) product group by ensuring users receive valuable new features. experience: | - - 💭 3-5 years' of experience in backend/SaaS development. + - 💭 3-5 years of experience in backend/SaaS development. - 🦉 Proficient in backend development. You practice OOP design and are comfortable in a lean software development environment. - 🦉 Translate requirements into well-designed and functional software. - 🤝 Communicate regularly with stakeholders, project managers, quality assurance teams, and other developers regarding progress on long-term technology roadmap. @@ -36,26 +60,4 @@ - 🛠️ Technical: You understand the software development processes. You understand that software quality matters. - 🟣 Openness: You are flexible and open to new ideas and ways of working. - ➕ Bonus: Cybersecurity or IT background. -- jobTitle: 🐋 Solutions Consultant - department: Sales - hiringManagerName: Dave Herder - hiringManagerLinkedInUrl: https://linkedin.com/in/daveherder - hiringManagerGithubUsername: dherder - responsibilities: | - - ⏫ Work hand-in-hand with the Sales team by participating in calls with potential customers to show them a demonstration of Fleet in action. - - 📖 You’ll provide commentary, detailed technical explanations, examples from your experience, and answer customer questions based on your experience managing Apple, Windows, and Linux devices with MDM and other tools (osquery). - - 🏃‍♂️ Provide internal technical training, and participate in sales enablement activities to ensure our team is prepared to explain how Fleet works, where we fit in the Security and IT ecosystem, and how we can solve problems with our customers. - experience: | - - 🦉 3+ years of experience in a technical sales role (Solutions Consultant, Sales Engineer, Solutions Architect, Technical Account Manager, etc) in the device management or cybersecurity space. - - 🧑‍🔬 Experience working with Enterprise customers to help resolve complex technical issues. - - 💭 Cybersecurity or IT background, experience with device management solutions like Fleet, Intune, Jamf Pro, Workspace One, etc. in addition to EDR platforms like Crowdstrike, SentinelOne, CarbonBlack, etc. - - ➕ Familiarity with GitOps workflows and steps to contribute code in open source projects. - - 🛠️ Has deployed infrastructure via some form of CI tooling to at least one of the big cloud platforms and lived to tell the tale. - - 💖 An excellent understanding of macOS, Windows, Linux and core services like Autopilot, ABM/ASM, MDM, ADE, APNs, syslog, etc. - - ✍️ Familiarity with SQLite, shell scripting, Python, Powershell, and using Terminal to execute commands or run scripts. - - 🎯 Strong attention to detail and can act as an encyclopedia of knowledge about how Fleet works - our potential customers represent a wide range of needs across many different use cases. Be adaptable to learning new things quickly and then share this knowledge with others. - - 💡 Excellent communication and collaboration skills, with the ability to work closely with sales, engineering, and product teams. - - 🌐 Coordinate with our Customer Success team to assist with any technical questions during renewal discussions. You’ll be a resource for existing customers too. - - 👥 A customer-centric mindset, focusing on delivering value and a positive user experience. - - 🤝 Collaboration: You work best in a participatory, team-based environment. - - 🟣 Openness: You are flexible and open to new ideas and ways of working. + diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index f29ddac991..13e19b9388 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -12,6 +12,189 @@ # jamfProHasFeature: Whether or not Jamf Pro has this (or a comparable) feature. Supported values: "yes", "no" or "appleOnly" (currently not used by Fleet website UI) # jamfProtectHasFeature: Whether or not Jamf Protext has this (or a comparable) feature. Supported values: "yes", "no" or "appleOnly" (currently not used by Fleet website UI) # +# +# +# ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗ +# ██╔══██╗██╔════╝██╔══██╗██║ ██╔═══██╗╚██╗ ██╔╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝ +# ██║ ██║█████╗ ██████╔╝██║ ██║ ██║ ╚████╔╝ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║ +# ██║ ██║██╔══╝ ██╔═══╝ ██║ ██║ ██║ ╚██╔╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║ +# ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ██║ ╚═╝ ██║███████╗██║ ╚████║ ██║ +# ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ +# +# +# ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗ ╔═╗╦ ╔═╗╦ ╦╔╦╗ +# ║║║╠═╣║║║╠═╣║ ╦║╣ ║║ ║ ║ ║ ║║ ║ ║║ +# ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝═╩╝ ╚═╝╩═╝╚═╝╚═╝═╩╝ +- industryName: Managed cloud + description: Have Fleet host it for you (currently only available for customers with 700+ hosts. PS. Wish we could host for you? We're working on it! Please let us know if you know of a good partner. In the meantime, join fleetdm.com/support and we're happy to help you deploy Fleet yourself.) + pricingTableCategories: [Deployment] + productCategories: [Endpoint operations,Device management,Vulnerability management] + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╔═╗╔═╗╦ ╔═╗ ╦ ╦╔═╗╔═╗╔╦╗╔═╗╔╦╗ +# ╚═╗║╣ ║ ╠╣───╠═╣║ ║╚═╗ ║ ║╣ ║║ +# ╚═╝╚═╝╩═╝╚ ╩ ╩╚═╝╚═╝ ╩ ╚═╝═╩╝ +- industryName: Self-hosted + friendlyName: Host it yourself + description: Deploy Fleet anywhere and host it yourself, even in air-gapped environments except where technologically impossible. + pricingTableCategories: [Deployment] + documentationUrl: https://fleetdm.com/docs/deploy/introduction + productCategories: [Endpoint operations,Device management,Vulnerability management] + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: no + buzzwords: [Self-hosted] +# +# ╔╦╗╦ ╦╦ ╔╦╗╦ ╔╦╗╔═╗╔╗╔╔═╗╔╗╔╔═╗╦ ╦ +# ║║║║ ║║ ║ ║───║ ║╣ ║║║╠═╣║║║║ ╚╦╝ +# ╩ ╩╚═╝╩═╝╩ ╩ ╩ ╚═╝╝╚╝╩ ╩╝╚╝╚═╝ ╩ +- industryName: Multi-tenancy + description: For managed service providers to use a single instance of Fleet for multiple customers. + documentationUrl: https://github.com/fleetdm/fleet/issues/9956 + productCategories: [Device management] + pricingTableCategories: [Deployment] + usualDepartment: IT + buzzwords: [OEM,Private label,House brand,Clear label,Multi-tenancy] + tier: Premium +# +# ╔╦╗╔═╗╔═╗╦ ╔═╗╦ ╦╔╦╗╔═╗╔╗╔╔╦╗ ╔╦╗╔═╗╔═╗╦ ╔═╗ +# ║║║╣ ╠═╝║ ║ ║╚╦╝║║║║╣ ║║║ ║ ║ ║ ║║ ║║ ╚═╗ +# ═╩╝╚═╝╩ ╩═╝╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩ ╚═╝╚═╝╩═╝╚═╝ +- industryName: Deployment tools + description: Pre-built Terraform modules and Helm charts to help you get up and running. + documentationUrl: https://fleetdm.com/docs/deploy/introduction + usualDepartment: IT + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Deployment] +# +# +# ██████╗ ██████╗ ███╗ ██╗███████╗██╗ ██████╗ ██╗ ██╗██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ +# ██╔════╝██╔═══██╗████╗ ██║██╔════╝██║██╔════╝ ██║ ██║██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ +# ██║ ██║ ██║██╔██╗ ██║█████╗ ██║██║ ███╗██║ ██║██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ +# ██║ ██║ ██║██║╚██╗██║██╔══╝ ██║██║ ██║██║ ██║██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ +# ╚██████╗╚██████╔╝██║ ╚████║██║ ██║╚██████╔╝╚██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ +# ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ +# +# +# ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╦ ╦╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╦═╗╔═╗╔═╗╦╔═╗╔╦╗╦═╗╦ ╦ +# ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║ ║╠═╝ ║║╠═╣ ║ ║╣ ╠╦╝║╣ ║ ╦║╚═╗ ║ ╠╦╝╚╦╝ +# ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╚═╝╩ ═╩╝╩ ╩ ╩ ╚═╝ ╩╚═╚═╝╚═╝╩╚═╝ ╩ ╩╚═ ╩ +- industryName: Private update registry + friendlyName: Update agents from a secret URL + description: Load agent code from a secret URL that you manage. + documentationUrl: https://fleetdm.com/docs/using-fleet/update-agents + tier: Premium + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Configuration] + usualDepartment: Security +# +# ╔═╗╔═╗╔╗╔╔╦╗╦═╗╔═╗╦ ╔═╗╔═╗╔═╗╔╗╔╔╦╗ ╦ ╦╔═╗╦═╗╔═╗╦╔═╗╔╗╔╔═╗ +# ║ ║ ║║║║ ║ ╠╦╝║ ║║ ╠═╣║ ╦║╣ ║║║ ║ ╚╗╔╝║╣ ╠╦╝╚═╗║║ ║║║║╚═╗ +# ╚═╝╚═╝╝╚╝ ╩ ╩╚═╚═╝╩═╝ ╩ ╩╚═╝╚═╝╝╚╝ ╩ ╚╝ ╚═╝╩╚═╚═╝╩╚═╝╝╚╝╚═╝ +- industryName: Control agent versions + description: Manage agents remotely by setting different versions per-baseline. + documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration#configure-fleetd-update-channels + tier: Premium + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Configuration] + usualDepartment: IT + waysToUse: + - description: Supply-chain Levels for Software Artifacts (SLSA) attestations for the fleetd binary artifacts and server container image to enable verification that the binaries are built and uploaded using GitHub Actions from the Fleet repository at a particular commit SHA coming soon (2024-12-31). + - moreInfoUrl: https://github.com/fleetdm/fleet/issues/20219 #customer-figali +# +# ╔═╗╔═╗╔╦╗╔╦╗╔═╗╔╗╔╔╦╗ ╦ ╦╔╗╔╔═╗ ╔╦╗╔═╗╔═╗╦ ┌─ ╔═╗╦ ╦ ─┐ +# ║ ║ ║║║║║║║╠═╣║║║ ║║ ║ ║║║║║╣ ║ ║ ║║ ║║ │ ║ ║ ║ │ +# ╚═╝╚═╝╩ ╩╩ ╩╩ ╩╝╚╝═╩╝ ╩═╝╩╝╚╝╚═╝ ╩ ╚═╝╚═╝╩═╝ └─ ╚═╝╩═╝╩ ─┘ +- industryName: Command line tool (CLI) + friendlyName: fleetctl + documentationUrl: https://fleetdm.com/docs/using-fleet/fleetctl-cli + productCategories: [Endpoint operations,Device management] + pricingTableCategories: [Configuration] + usualDepartment: IT + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╔═╗╦╔╦╗╔═╗╔═╗╔═╗ +# ║ ╦║ ║ ║ ║╠═╝╚═╗ +# ╚═╝╩ ╩ ╚═╝╩ ╚═╝ +- industryName: GitOps + friendlyName: Manage endpoints in git + documentationUrl: https://github.com/fleetdm/fleet-gitops + description: Fork the best practices GitHub repo and use the included GitHub Actions to quickly automate Fleet console and configuration workflow management. + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Configuration] + usualDepartment: IT + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + demos: + description: A top savings and investment company wanted workflows and automation so that one bad actor can't brick their fleet. This way, they have to make a pull request first. + quote: I don't want one bad actor to brick my fleet. I want them to make a pull request first. + moreInfoUrl: https://docs.google.com/document/d/1hAQL6P--Tt3syq1MTRONAxhQA_2Vjt3oOJJt_O4xbiE/edit?disco=AAABAVnYvns&usp_dm=true#heading=h.7en766pueek4 +# +# ╔╦╗╦ ╦╔═╗ ╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗ ╔═╗╦ ╦╔╦╗╦ ╦╔═╗╔╗╔╔╦╗╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ +# ║ ║║║║ ║───╠╣ ╠═╣║ ║ ║ ║╠╦╝ ╠═╣║ ║ ║ ╠═╣║╣ ║║║ ║ ║║ ╠═╣ ║ ║║ ║║║║ +# ╩ ╚╩╝╚═╝ ╚ ╩ ╩╚═╝ ╩ ╚═╝╩╚═ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ +- industryName: Two-factor authentication + moreInfoUrl: https://github.com/fleetdm/fleet/issues/5478 + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Configuration] + usualDepartment: IT + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + waysToUse: + - description: Enforce two-factor authentication when logging in to Fleet for added security. + comingSoonOn: 2024-12-31 #customer-rosner +# +# ╦═╗╔═╗╦ ╔═╗ ╔╗ ╔═╗╔═╗╔═╗╔╦╗ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗ ╔═╗╔═╗╔╗╔╔╦╗╦═╗╔═╗╦ +# ╠╦╝║ ║║ ║╣───╠╩╗╠═╣╚═╗║╣ ║║ ╠═╣║ ║ ║╣ ╚═╗╚═╗ ║ ║ ║║║║ ║ ╠╦╝║ ║║ +# ╩╚═╚═╝╩═╝╚═╝ ╚═╝╩ ╩╚═╝╚═╝═╩╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ ╚═╝╚═╝╝╚╝ ╩ ╩╚═╚═╝╩═╝ +- industryName: Role-based access control + documentationUrl: https://fleetdm.com/docs/using-fleet/manage-access#manage-access + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Configuration] + usualDepartment: IT + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╔═╗╦ ╦╔╦╗╦╔╦╗ ╦ ╔═╗╔═╗╔═╗╦╔╗╔╔═╗ +# ╠═╣║ ║ ║║║ ║ ║ ║ ║║ ╦║ ╦║║║║║ ╦ +# ╩ ╩╚═╝═╩╝╩ ╩ ╩═╝╚═╝╚═╝╚═╝╩╝╚╝╚═╝ +- industryName: Audit logging + description: Log all activity, including queries, scripts, access, etc. + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#list-activities + productCategories: [Endpoint operations, Device management] + pricingTableCategories: [Configuration] + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes + usualDepartment: Security + waysToUse: + - description: Export activity of Fleet admins to your SIEM or data lake +# +# ╔═╗╔═╗╔═╗╔═╗╔═╗ ╔╦╗╦═╗╔═╗╔╗╔╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔╔═╗╦ ╦ +# ╚═╗║ ║ ║╠═╝║╣ ║ ╠╦╝╠═╣║║║╚═╗╠═╝╠═╣╠╦╝║╣ ║║║║ ╚╦╝ +# ╚═╝╚═╝╚═╝╩ ╚═╝ ╩ ╩╚═╩ ╩╝╚╝╚═╝╩ ╩ ╩╩╚═╚═╝╝╚╝╚═╝ ╩ +- industryName: Scope transparency + description: Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn’t acceptable use of work computers. + tier: Free + documentationUrl: https://fleetdm.com/transparency + productCategories: [Endpoint operations] + pricingTableCategories: [Configuration] +# +# # ██████╗ ███████╗██╗ ██╗██╗ ██████╗███████╗ # ██╔══██╗██╔════╝██║ ██║██║██╔════╝██╔════╝ # ██║ ██║█████╗ ██║ ██║██║██║ █████╗ @@ -31,14 +214,14 @@ # ║ ╠╦╝║ ║╚═╗╚═╗───╠═╝║ ╠═╣ ║ ╠╣ ║ ║╠╦╝║║║ ║║║ ║║║║║ ╚═╗║ ║╠═╝╠═╝║ ║╠╦╝ ║ # ╚═╝╩╚═╚═╝╚═╝╚═╝ ╩ ╩═╝╩ ╩ ╩ ╚ ╚═╝╩╚═╩ ╩ ╩ ╩═╩╝╩ ╩ ╚═╝╚═╝╩ ╩ ╚═╝╩╚═ ╩ - industryName: Cross-platform MDM support - description: macOS, Windows, and Linux. - documentationUrl: https://fleetdm.com/announcements/fleet-introduces-windows-mdm + description: Apple, Windows, and Linux. + documentationUrl: https://fleetdm.com/announcements/debunk-the-cross-platform-myth tier: Premium jamfProHasFeature: appleOnly jamfProtectHasFeature: no usualDepartment: IT productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] # # ╔╦╗╔╦╗╔╦╗ ╔╦╗╦╔═╗╦═╗╔═╗╔╦╗╦╔═╗╔╗╔ # ║║║ ║║║║║ ║║║║║ ╦╠╦╝╠═╣ ║ ║║ ║║║║ @@ -51,29 +234,44 @@ documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-migration-guide usualDepartment: IT productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] # # ╔═╗╔═╗╦═╗╔═╗ ╔╦╗╔═╗╦ ╦╔═╗╦ ╦ ╔═╗╔═╗╔╦╗╦ ╦╔═╗ # ╔═╝║╣ ╠╦╝║ ║───║ ║ ║║ ║║ ╠═╣ ╚═╗║╣ ║ ║ ║╠═╝ # ╚═╝╚═╝╩╚═╚═╝ ╩ ╚═╝╚═╝╚═╝╩ ╩ ╚═╝╚═╝ ╩ ╚═╝╩ - industryName: Zero-touch setup - description: Zero-touch setup for macOS, iOS/iPadOS (coming soon), and Windows. + description: Zero-touch setup for macOS, iOS/iPadOS, and Windows. documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience tier: Premium jamfProHasFeature: appleOnly jamfProtectHasFeature: no usualDepartment: IT productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] waysToUse: - - description: Zero-touch for iOS/iPadOS is coming soon (2024-07-15). - - description: Ship a macOS workstation to the end user's home and have them automatically enroll to Fleet during out-of-the-box setup. + - description: Ship a macOS, iOS, or iPadOS device to the end user's home and have them automatically enroll to Fleet during out-of-the-box setup. - description: Ship a Windows workstation to the end user's home and have them automatically enroll to Fleet during out-of-the-box setup. - description: Customize the out-of-the-box setup experience for your end users. - description: Install a bootstrap package to run custom scripts during the setup experience. Store the bootstrap package outside the Fleet database coming soon (2024-09-15) #customer-faltona moreInfoUrl: https://github.com/fleetdm/fleet/issues/19037 - description: Require end users to authenticate with your identity provider (IdP) and agree to an end user license agreement (EULA) before they can use their new workstation # +# ╔╗ ╦ ╦╔═╗╔╦╗ ╔═╗╔╗╔╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╔╗╔╔╦╗ +# ╠╩╗╚╦╝║ ║ ║║ ║╣ ║║║╠╦╝║ ║║ ║ ║║║║╣ ║║║ ║ +# ╚═╝ ╩ ╚═╝═╩╝ ╚═╝╝╚╝╩╚═╚═╝╩═╝╩═╝╩ ╩╚═╝╝╚╝ ╩ +- industryName: Bring your own device (BYOD) enrollment + description: BYOD enrollment for macOS, iOS/iPadOS (coming soon), Windows, and Android (coming soon) devices. + documentationUrl: https://fleetdm.com/guides/sysadmin-diaries-device-enrollment#byod-enrollment + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: no + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] + waysToUse: + - description: Support ACME as a protocol for MDM certificate generation. Coming soon (2025-03-31) #customer-rosner + moreInfoUrl: https://github.com/fleetdm/fleet/issues/15611 +# # ╦ ╦╔═╗╔═╗╦═╗ ╔═╗╔═╗╔═╗╔═╗╦ ╦╔╗╔╔╦╗ ╔═╗╦ ╦╔╗╔╔═╗ # ║ ║╚═╗║╣ ╠╦╝ ╠═╣║ ║ ║ ║║ ║║║║ ║ ╚═╗╚╦╝║║║║ # ╚═╝╚═╝╚═╝╩╚═ ╩ ╩╚═╝╚═╝╚═╝╚═╝╝╚╝ ╩ ╚═╝ ╩ ╝╚╝╚═╝ @@ -81,43 +279,100 @@ description: Sync user accounts via Okta, AD, or any IDP. documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] usualDepartment: IT tier: Premium jamfProHasFeature: yes jamfProtectHasFeature: yes waysToUse: - description: Automatically set admin access to Fleet based on your IDP -# -# ╔╗ ╦ ╦╔═╗╔╦╗ ╔═╗╔╗╔╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╔╗╔╔╦╗ -# ╠╩╗╚╦╝║ ║ ║║ ║╣ ║║║╠╦╝║ ║║ ║ ║║║║╣ ║║║ ║ -# ╚═╝ ╩ ╚═╝═╩╝ ╚═╝╝╚╝╩╚═╚═╝╩═╝╩═╝╩ ╩╚═╝╝╚╝ ╩ -- industryName: BYOD enrollment - description: BYOD enrollment for macOS, iOS/iPadOS (coming soon), Windows, and Android (coming soon) devices. - documentationUrl: https://fleetdm.com/guides/sysadmin-diaries-device-enrollment#byod-enrollment +# +# ╦ ╦╦ ╦╔╦╗╔═╗╔╗╔ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗ ╔╦╗╔═╗╔═╗╔═╗╦╔╗╔╔═╗ +# ╠═╣║ ║║║║╠═╣║║║───║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ║║║╠═╣╠═╝╠═╝║║║║║ ╦ +# ╩ ╩╚═╝╩ ╩╩ ╩╝╚╝ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╩ ╩╩ ╩╩ ╩ ╩╝╚╝╚═╝ +- industryName: Human-endpoint mapping + friendlyName: See who logs in on every computer + description: Identify who logs in to any system, including login history and current sessions. Look up any host by the email address of the person using it. + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#get-hosts-google-chrome-profiles + screenshotSrc: tier: Free jamfProHasFeature: yes - jamfProtectHasFeature: no + jamfProtectHasFeature: yes + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] + buzzwords: [Device users,human-to-device mapping] + dri: mikermcneil + demos: + - description: Security engineers at a top gaming company wanted to get demographics off their macOS, Windows, and Linux machines about who the user is and who's logged in. + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + - description: Data engineers at a top biotech corporation needed to know who is logged into their devices. + quote: So we don't know exactly what's going on after we deploy the device, we know that they are compliant with the security because we are running these stuff, but we don't know certainly who is running, who is logging in the device? + moreInfoUrl: https://docs.google.com/document/d/17MNI5ykzlFjdVmQ8SPMrT1oR_hY_vkYAJx31F7l7Pv8/edit#heading=h.7en766pueek4 waysToUse: - - description: Support ACME as a protocol for MDM certificate generation. Coming soon (2024-12-31) #customer-rosner - moreInfoUrl: https://github.com/fleetdm/fleet/issues/15611 + - description: Look up computer by ActiveDirectory account + - description: Find device by Google Chrome user + - description: Identify who logs in to any system, including login history and current sessions. + - description: Look up any host by the email address of the person using it. + - description: Check user login history + moreInfoUrl: https://www.lepide.com/how-to/audit-who-logged-into-a-computer-and-when.html#:~:text=To%20find%20out%20the%20details,logs%20in%20%E2%80%9CWindows%20Logs%E2%80%9D. + - description: See currently logged in users + moreInfoUrl: https://www.top-password.com/blog/see-currently-logged-in-users-in-windows/ + - description: Get demographics off of our machines about who the user is and who's logged in + moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit + - description: See what servers someone is logged-in on + moreInfoUrl: https://community.spiceworks.com/topic/138171-is-there-a-way-to-see-what-servers-someone-is-logged-in-on # # ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ # ║║║╣ ╚╗╔╝║║ ║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ # ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ - industryName: Device inventory - description: The device inventory allows admins to view device data. + description: Includes a list of all devices and all hardware and software attributes for each device. documentationUrl: https://fleetdm.com/docs/using-fleet/understanding-host-vitals moreInfoUrl: https://github.com/fleetdm/fleet/issues/14415 tier: Free jamfProHasFeature: yes jamfProtectHasFeature: yes usualDepartment: IT + productCategories: [Endpoint operations,Device management, Vulnerability management] + pricingTableCategories: [Devices] + waysToUse: + - description: Implement software inventory recommendations from the SANS 20 / CIS 18. + moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 + - description: View a list of all hardware attributes of a device. + moreInfoUrl: https://fleetdm.com/tables/system_info + - description: View a list of all software and their versions installed on all your hosts. + moreInfoUrl: https://fleetdm.com/docs/get-started/anatomy#software-library + - description: View a list of software rolled up by title. + moreInfoUrl: https://github.com/fleetdm/fleet/issues/14674 + - description: Implement hardware and infrastructure inventory recommendations from the SANS 20 / CIS 18. + moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 +# +# ╔═╗╔═╗╔═╗╦═╗╔═╗╦ ╦ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ +# ╚═╗║╣ ╠═╣╠╦╝║ ╠═╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ +# ╚═╝╚═╝╩ ╩╩╚═╚═╝╩ ╩ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ +- industryName: Search inventory + description: Search devices by IP, serial, hostname, and UUID. + documentationUrl: https://fleetdm.com/docs/using-fleet/learn-how-to-use-fleet#how-to-ask-questions-about-your-device productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] + usualDepartment: IT + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╔═╗╔═╗╔═╗╔═╗╦╔╗╔╔═╗ +# ║ ╠═╣╠╦╝║ ╦║╣ ║ ║╣ ║║ ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗║ ║ ║╠═╝║║║║║ ╦ +# ╩ ╩ ╩╩╚═╚═╝╚═╝ ╩ ╚═╝═╩╝ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╚═╝╚═╝╚═╝╩ ╩╝╚╝╚═╝ +- industryName: Targeted device scoping + description: Organize devices with Teams and Labels. + documentationUrl: https://fleetdm.com/guides/managing-labels-in-fleet + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] # # ╔═╗╔╗╔╔═╗╔═╗╦═╗╔═╗╔═╗ ╔╦╗╦╔═╗╦╔═ ╔═╗╔╗╔╔═╗╦═╗╦ ╦╔═╗╔╦╗╦╔═╗╔╗╔ # ║╣ ║║║╠╣ ║ ║╠╦╝║ ║╣ ║║║╚═╗╠╩╗ ║╣ ║║║║ ╠╦╝╚╦╝╠═╝ ║ ║║ ║║║║ @@ -127,7 +382,7 @@ documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-disk-encryption friendlyName: Ensure hard disks are encrypted productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] usualDepartment: Security tier: Premium jamfProHasFeature: appleOnly @@ -149,7 +404,7 @@ jamfProtectHasFeature: no usualDepartment: IT productCategories: [Device management,Vulnerability management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] waysToUse: - description: Enforce macOS updates via Nudge. - description: Progressively enhance from Nudge to DDM-based OS updates. @@ -171,17 +426,147 @@ moreInfoUrl: https://github.com/fleetdm/fleet/issues/13281 - description: Deploy custom declaration (DDM) profiles on macOS. moreInfoUrl: https://github.com/fleetdm/fleet/issues/14550 - - description: Target profiles to specific hosts using SQL. Exclusions coming soon (2024-07-15) #customer-rosner + - description: Target profiles to specific hosts using SQL. moreInfoUrl: https://github.com/fleetdm/fleet/issues/17315 - description: Automatically re-deploy configuration profiles when they're not installed. - - description: Deploy configuration profiles on iOS/iPadOS. Coming soon (2024-07-15). + - description: Deploy configuration profiles on iOS/iPadOS. - description: See a list of the upcoming MDM commands and scripts in unified queue. Coming soon (2024-07-15) moreInfoUrl: https://github.com/fleetdm/fleet/issues/15920 - - description: MDM commands for iOS/iPadOS are coming soon (2024-07-15). - description: Send MDM commands to tell end users to update their OS. moreInfoUrl: https://developer.apple.com/documentation/devicemanagement/schedule_an_os_update + - description: Configure agent options remotely, over the air. (Includes osquery config, and osquery startup flags.). + moreInfoUrl: https://fleetdm.com/docs/configuration/agent-configuration productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] +# +# ╔╦╗╔═╗╔═╗╦ ╔═╗╦═╗╔═╗╔╦╗╦╦ ╦╔═╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ +# ║║║╣ ║ ║ ╠═╣╠╦╝╠═╣ ║ ║╚╗╔╝║╣ ║║║╣ ╚╗╔╝║║ ║╣ ║║║╠═╣║║║╠═╣║ ╦║╣ ║║║║╣ ║║║ ║ +# ═╩╝╚═╝╚═╝╩═╝╩ ╩╩╚═╩ ╩ ╩ ╩ ╚╝ ╚═╝ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ +- industryName: Declarative Device Management (DDM) support for configuration profiles + description: Full support for Apple DDM configuration profiles. + documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-os-updates#macos + tier: Free + jamfProHasFeature: cloudOnly + jamfProtectHasFeature: cloudOnly + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] +# +# ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦ ╦╔═╗╔═╗╦ ╔╦╗╦ ╦ +# ║║║╣ ╚╗╔╝║║ ║╣ ╠═╣║╣ ╠═╣║ ║ ╠═╣ +# ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩ ╩╚═╝╩ ╩╩═╝╩ ╩ ╩ +- industryName: Device health + friendlyName: Automate device health + description: Automatically report system health issues using webhooks or integrations, to notify or quarantine outdated or misconfigured systems that are at higher risk of vulnerabilities or theft. + documentationUrl: https://fleetdm.com/docs/using-fleet/automations#automations + screenshotSrc: + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: yes + productCategories: [Device management,Endpoint operations] + pricingTableCategories: [Devices] + usualDepartment: IT + dri: mikermcneil + demos: + - description: A large tech company used the Fleet API to block access to corporate apps for outdated operating system versions with certain "celebrity" vulnerabilities. + quote: + moreInfoUrl: https://play.goconsensus.com/s4e490bb9 + buzzwords: [Device trust,Zero trust,Layer 7 device trust,Beyondcorp,Device attestation,Conditional access] + waysToUse: + - description: Automatically manage the behavior of endpoints that are at higher risk of vulnerabilities or data loss due to their configuration or patch level. + - description: Block access to corporate apps for users whose devices with unexpected settings, like disabled screen lock, passwords that are too short, unencrypted hard disks, and more + - description: Quickly implement conditional access based on device health using osquery and a simple device health REST API. + moreInfoUrl: https://github.com/fleetdm/fleet/issues/14920 + - description: Control and restore access to applications by automatically restricting access when devices do not meet particular security requirements. + moreInfoUrl: https://duo.com/docs/device-health + - description: Control which laptop and desktop devices can access corporate apps and websites based on what vulnerabilities it might be exposed to based on how the device is configured, whether it's up to date, its MDM enrollment status, and anything else you can build in a SQL query of Fleet's 300 data tables representing information about enrolled host systems. Coming soon (2024-09-30). + moreInfoUrl: https://github.com/fleetdm/fleet/issues/16236 + - description: Implement multivariate device trust + moreInfoUrl: https://youtu.be/5sFOdpMLXQg?feature=shared&t=1445 + - description: Implement your own version of Google's zero trust model (BeyondCorp) + moreInfoUrl: https://cloud.google.com/beyondcorp + - description: Get endpoint data into ServiceNow and make your asset management teams happy + moreInfoUrl: https://www.youtube.com/watch?v=aVbU6_9JoM0 + - description: Monitor devices that don't meet your organization's custom security policies + - description: Quickly report your posture and vulnerabilities to auditors, showing remediation status and timing. + - description: Keep your devices compliant with customizable baselines, or use common benchmarks like CIS. + - description: Discover security misconfigurations that increase attack surface. + - description: Detect suspcious services listening on open ports that should not be connected to the internet, such as Remote Desktop Protocol (RDP). + moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20statename%20%3D%20%E2%80%9CEnabled%E2%80%9D-,OPEN%20SOCKETS,-Lastly%2C%20an%20examination + - description: Discover potentially unwanted programs that increase attack surface. + moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/ + - description: Detect self-signed certifcates + - description: Detect legacy protocols with safer versions + moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20self_signed%20%3D%201%3B-,LEGACY%20PROTOCOLS,-This%20section%20will + - description: Detect exposed secrets on the command line + moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WDigest%20is%20disabled.-,EXPOSED%20SECRETS,-Often%2C%20to%20create + - description: Detect and surface issues with devices + - description: Share device health reports + - description: Align endpoints with your security policies + moreInfoUrl: https://www.axonius.com/use-cases/cmdb-reconciliation + - description: Maximize security control coverage + - description: Uncover gaps in security policies, configurations, and hygiene + moreInfoUrl: https://www.axonius.com/use-cases/coverage-gap-discovery + - description: Automatically apply security policies to protect endpoints against attack. + - description: Surface security issues in all your deployed endpoints even data centers and factories. + - description: Continually validate controls and policies + - description: Block access to corporate apps if your end users are failing a specific number of critical policies. + moreInfoUrl: https://github.com/fleetdm/fleet/issues/16206 +# +# ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔═╗╦ ╔═╗╦ ╦╔╦╗╔═╗╔╗╔╔╦╗ +# ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║╣ ╠═╝║ ║ ║╚╦╝║║║║╣ ║║║ ║ +# ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ═╩╝╚═╝╩ ╩═╝╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ +- industryName: Application deployment + description: Deploy applications and security agents on macOS, iOS/iPadOS, Linux, Windows, and Android (coming soon) devices. Additionally, install macOS and iOS/iPadOS apps from the App Store (coming soon). + tier: Premium + jamfProHasFeature: appleOnly + jamfProtectHasFeature: no + isExperimental: yes + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] + moreInfoUrl: https://github.com/fleetdm/fleet/issues/18867 + waysToUse: + - description: Easily configure and install SentinelOne, Crowdstrike, and other security tools. + moreInfoUrl: https://github.com/fleetdm/fleet/issues/14921 + - description: Offer licenses for Photoshop and other App Sore apps for your end users. + - description: iOS/iPadOS coming soon (2024-08-11). + moreInfoUrl: https://github.com/fleetdm/fleet/issues/14899 +# +# ╔═╗╔═╗╦ ╔═╗ ╔═╗╔═╗╦═╗╦ ╦╦╔═╗╔═╗ ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗╦╔═╗╔╗╔ +# ╚═╗║╣ ║ ╠╣───╚═╗║╣ ╠╦╝╚╗╔╝║║ ║╣ ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║║╚═╗ ║ ╠═╣║ ║ ╠═╣ ║ ║║ ║║║║ +# ╚═╝╚═╝╩═╝╚ ╚═╝╚═╝╩╚═ ╚╝ ╩╚═╝╚═╝ ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╩ ╩ ╩ ╩╚═╝╝╚╝ +- industryName: Self-service application installation + description: Allow end users to install apps through Fleet Desktop for macOS, Linux, and Windows. + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: no + isExperimental: yes + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] + moreInfoUrl: https://github.com/fleetdm/fleet/issues/17587 + waysToUse: + - description: Build scripts for Ansible deployments + moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 + - description: Deploy osquery to macOS via Jamf + moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 + - description: Package osquery for Linux servers via Workspace One and Windows servers via group policies + moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 +# +# ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ +# ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║╠═╣║║║╠═╣║ ╦║╣ ║║║║╣ ║║║ ║ +# ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ +- industryName: Application management + description: Manage updates and patches for apps on macOS, Windows, and Linux computers. + tier: Premium + jamfProHasFeature: appleOnly + jamfProtectHasFeature: no + comingSoonOn: 2024-08-25 + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Devices] + moreInfoUrl: https://github.com/fleetdm/fleet/issues/18865 # # ╔═╗╔═╗╦═╗╦╔═╗╔╦╗ ╔═╗═╗ ╦╔═╗╔═╗╦ ╦╔╦╗╦╔═╗╔╗╔ # ╚═╗║ ╠╦╝║╠═╝ ║ ║╣ ╔╩╦╝║╣ ║ ║ ║ ║ ║║ ║║║║ @@ -196,7 +581,7 @@ dri: mikermcneil usualDepartment: IT productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] demos: - description: A large tech company used scripts to fix issues with their security and compliance agents on workstations. buzzwords: [Remote script execution,PowerShell scripts,Bash scripts] @@ -216,57 +601,7 @@ - description: Run scripts on online/offline hosts moreInfoUrl: https://github.com/fleetdm/fleet/issues/15529 - description: Only maintainers and admins can run scripts. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/19055 -# -# ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔═╗╦ ╔═╗╦ ╦╔╦╗╔═╗╔╗╔╔╦╗ -# ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║╣ ╠═╝║ ║ ║╚╦╝║║║║╣ ║║║ ║ -# ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ═╩╝╚═╝╩ ╩═╝╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ -- industryName: Application deployment - description: Deploy applications and security agents on macOS, iOS/iPadOS, Linux, Windows, and Android (coming soon) devices. Additionally, install macOS and iOS/iPadOS apps from the App Store (coming soon). - tier: Premium - jamfProHasFeature: appleOnly - jamfProtectHasFeature: no - isExperimental: yes - usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] - moreInfoUrl: https://github.com/fleetdm/fleet/issues/18867 - waysToUse: - - description: Easily configure and install SentinelOne, Crowdstrike, and other security tools. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/14921 - - description: Offer licenses for Photoshop and other App Sore apps for your end users. - - description: macOS coming soon (2024-07-15). #customer-rosner - moreInfoUrl: https://github.com/fleetdm/fleet/issues/18867 - - description: iOS/iPadOS coming soon (2024-08-11). - moreInfoUrl: https://github.com/fleetdm/fleet/issues/14899 -# -# ╔═╗╔═╗╦ ╔═╗ ╔═╗╔═╗╦═╗╦ ╦╦╔═╗╔═╗ ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗╦╔═╗╔╗╔ -# ╚═╗║╣ ║ ╠╣ ╚═╗║╣ ╠╦╝╚╗╔╝║║ ║╣ ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║║╚═╗ ║ ╠═╣║ ║ ╠═╣ ║ ║║ ║║║║ -# ╚═╝╚═╝╩═╝╚ ╚═╝╚═╝╩╚═ ╚╝ ╩╚═╝╚═╝ ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╩ ╩ ╩ ╩╚═╝╝╚╝ -- industryName: Self service application installation - description: Allow end users to install apps through Fleet Desktop for macOS, Linux, and Windows. - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: no - isExperimental: yes - usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] - moreInfoUrl: https://github.com/fleetdm/fleet/issues/17587 -# -# ╔═╗╔═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ -# ╠═╣╠═╝╠═╝║ ║║ ╠═╣ ║ ║║ ║║║║ ║║║╠═╣║║║╠═╣║ ╦║╣ ║║║║╣ ║║║ ║ -# ╩ ╩╩ ╩ ╩═╝╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ -- industryName: Application management - description: Manage updates and patches for apps on macOS, Windows, and Linux computers. - tier: Premium - jamfProHasFeature: appleOnly - jamfProtectHasFeature: no - comingSoonOn: 2024-08-25 - usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] - moreInfoUrl: https://github.com/fleetdm/fleet/issues/18865 + moreInfoUrl: https://github.com/fleetdm/fleet/issues/19055 # # ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦═╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗╔╦╗╦╔═╗╔╗╔ # ║║║╣ ╚╗╔╝║║ ║╣ ╠╦╝║╣ ║║║║╣ ║║║╠═╣ ║ ║║ ║║║║ @@ -279,9 +614,23 @@ jamfProtectHasFeature: no usualDepartment: IT productCategories: [Device management, Vulnerability management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] waysToUse: - description: Send software vulnerability emails to end users to encourage self-remediation. +# +# ╔╦╗╔═╗╦╔╗╔╔╦╗╔═╗╔╗╔╔═╗╔╗╔╔═╗╔═╗ ╦ ╦╦╔╗╔╔╦╗╔═╗╦ ╦╔═╗ +# ║║║╠═╣║║║║ ║ ║╣ ║║║╠═╣║║║║ ║╣ ║║║║║║║ ║║║ ║║║║╚═╗ +# ╩ ╩╩ ╩╩╝╚╝ ╩ ╚═╝╝╚╝╩ ╩╝╚╝╚═╝╚═╝ ╚╩╝╩╝╚╝═╩╝╚═╝╚╩╝╚═╝ +- industryName: Maintenance windows + friendlyName: Fleet in your calendar + description: Create a calendar event to auto-remediate failing policies when your end users are free. + documentationUrl: https://github.com/fleetdm/fleet/issues/17230 + tier: Premium + jamfProHasFeature: no + jamfProtectHasFeature: no + isExperimental: yes + productCategories: [Device management, Endpoint operations] + pricingTableCategories: [Devices] # # ╔═╗╔═╗╔╗╔╔╦╗ ╦ ╔═╗╔═╗╦╔═ ╔═╗╔╗╔╔╦╗ ╦ ╦╦╔═╗╔═╗ ╔═╗╔═╗╔╦╗╔╦╗╔═╗╔╗╔╔╦╗╔═╗ # ╚═╗║╣ ║║║ ║║ ║ ║ ║║ ╠╩╗ ╠═╣║║║ ║║ ║║║║╠═╝║╣ ║ ║ ║║║║║║║╠═╣║║║ ║║╚═╗ @@ -299,36 +648,256 @@ jamfProtectHasFeature: no usualDepartment: IT productCategories: [Device management] - pricingTableCategories: [Device management] + pricingTableCategories: [Devices] # -# ╔═╗╔═╗╔═╗╦ ╔═╗ ╔╦╗╔═╗╔═╗╦ ╔═╗╦═╗╔═╗╔╦╗╦╦ ╦╔═╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ -# ╠═╣╠═╝╠═╝║ ║╣ ║║║╣ ║ ║ ╠═╣╠╦╝╠═╣ ║ ║╚╗╔╝║╣ ║║║╣ ╚╗╔╝║║ ║╣ ║║║╠═╣║║║╠═╣║ ╦║╣ ║║║║╣ ║║║ ║ -# ╩ ╩╩ ╩ ╩═╝╚═╝ ═╩╝╚═╝╚═╝╩═╝╩ ╩╩╚═╩ ╩ ╩ ╩ ╚╝ ╚═╝ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ -# ╔═╗╦ ╦╔═╗╔═╗╔═╗╦═╗╔╦╗ ╔═╗╔═╗╦═╗ ╔═╗╔═╗╔╗╔╔═╗╦╔═╗╦ ╦╦═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔═╗╦═╗╔═╗╔═╗╦╦ ╔═╗╔═╗ -# ╚═╗║ ║╠═╝╠═╝║ ║╠╦╝ ║ ╠╣ ║ ║╠╦╝ ║ ║ ║║║║╠╣ ║║ ╦║ ║╠╦╝╠═╣ ║ ║║ ║║║║ ╠═╝╠╦╝║ ║╠╣ ║║ ║╣ ╚═╗ -# ╚═╝╚═╝╩ ╩ ╚═╝╩╚═ ╩ ╚ ╚═╝╩╚═ ╚═╝╚═╝╝╚╝╚ ╩╚═╝╚═╝╩╚═╩ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╚═╚═╝╚ ╩╩═╝╚═╝╚═╝ -- industryName: Apple Declarative Device Management (DDM) support for configuration profiles - description: Use the latest device management protocol on your Apple devices. - documentationUrl: https://fleetdm.com/docs/using-fleet/mdm-os-updates#macos - tier: Premium - jamfProHasFeature: cloudOnly - jamfProtectHasFeature: cloudOnly +# +# ███████╗███╗ ██╗██████╗ ██████╗ ██████╗ ██╗███╗ ██╗████████╗ +# ██╔════╝████╗ ██║██╔══██╗██╔══██╗██╔═══██╗██║████╗ ██║╚══██╔══╝ +# █████╗ ██╔██╗ ██║██║ ██║██████╔╝██║ ██║██║██╔██╗ ██║ ██║ +# ██╔══╝ ██║╚██╗██║██║ ██║██╔═══╝ ██║ ██║██║██║╚██╗██║ ██║ +# ███████╗██║ ╚████║██████╔╝██║ ╚██████╔╝██║██║ ╚████║ ██║ +# ╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ +# +# ██████╗ ██████╗ ███████╗██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗ +# ██╔═══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝ +# ██║ ██║██████╔╝█████╗ ██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║███████╗ +# ██║ ██║██╔═══╝ ██╔══╝ ██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║╚════██║ +# ╚██████╔╝██║ ███████╗██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████║ +# ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ +# +# +# +# ╔═╗ ╦ ╦╔═╗╦═╗╦╔═╗╔═╗ +# ║═╬╗║ ║║╣ ╠╦╝║║╣ ╚═╗ +# ╚═╝╚╚═╝╚═╝╩╚═╩╚═╝╚═╝ +- industryName: Queries + description: Scheduled or saved queries with optional AI-generated descriptions, and, live queries for real-time data collection. + documentationUrl: https://fleetdm.com/docs/using-fleet/fleet-ui + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Devices] usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] + demos: + - description: A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers. + moreInfoUrl: https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6 +# +# ╔═╗ ╦ ╦╔═╗╦═╗╦ ╦ ╔═╗╔═╗╦═╗╔═╗╔═╗╦═╗╔╦╗╔═╗╔╗╔╔═╗╔═╗ ╔╦╗╔═╗╔╗╔╦╔╦╗╔═╗╦═╗╦╔╗╔╔═╗ +# ║═╬╗║ ║║╣ ╠╦╝╚╦╝ ╠═╝║╣ ╠╦╝╠╣ ║ ║╠╦╝║║║╠═╣║║║║ ║╣ ║║║║ ║║║║║ ║ ║ ║╠╦╝║║║║║ ╦ +# ╚═╝╚╚═╝╚═╝╩╚═ ╩ ╩ ╚═╝╩╚═╚ ╚═╝╩╚═╩ ╩╩ ╩╝╚╝╚═╝╚═╝ ╩ ╩╚═╝╝╚╝╩ ╩ ╚═╝╩╚═╩╝╚╝╚═╝ +- industryName: Query performance monitoring + documentationUrl: https://fleetdm.com/docs/get-started/faq#will-fleet-slow-down-my-servers-what-about-my-employee-laptops + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + demos: + - description: A top software company needed to understand the performance impact of osquery queries before running them on all of their production Linux servers. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + - description: A top software company wanted to detect regressions when adding/changing queries and fail builds if queries were too expensive. + moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg + waysToUse: + - description: Monitor performance for automated queries. + - description: Monitor performance for live queries. + moreInfoUrl: https://github.com/fleetdm/fleet/issues/467 # -# ╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╔═╗╔═╗╔═╗╔═╗╦╔╗╔╔═╗ -# ║ ╠═╣╠╦╝║ ╦║╣ ║ ║╣ ║║ ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗║ ║ ║╠═╝║║║║║ ╦ -# ╩ ╩ ╩╩╚═╚═╝╚═╝ ╩ ╚═╝═╩╝ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╚═╝╚═╝╚═╝╩ ╩╝╚╝╚═╝ -- industryName: Targeted device scoping - description: Organize devices with Teams and Labels. - documentationUrl: https://fleetdm.com/guides/managing-labels-in-fleet +# ╔═╗╦ ╦╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╔╗ ╦ ╔═╗╔═╗ +# ║ ║ ║╚═╗ ║ ║ ║║║║ ║ ╠═╣╠╩╗║ ║╣ ╚═╗ +# ╚═╝╚═╝╚═╝ ╩ ╚═╝╩ ╩ ╩ ╩ ╩╚═╝╩═╝╚═╝╚═╝ +- industryName: Custom tables + friendlyName: Add tables to osquery with extensions + description: Create your own osquery tables, extensions & automatic table configurations or disable existing tables to maintain PII or privacy. + documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration#extensions + moreInfoUrl: https://github.com/trailofbits/osquery-extensions/blob/3df2b72ad78549e25344c79dbc9bce6808c4d92a/README.md#extensions + tier: Premium + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + usualDepartment: IT +# +# ╦═╗╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╔═╗╔═╗╔╦╗╔╦╗╦╔╗╔╔═╗╔═╗ +# ╠╦╝║╣ ║║║║ ║ ║ ║╣ ╚═╗║╣ ║ ║ ║║║║║ ╦╚═╗ +# ╩╚═╚═╝╩ ╩╚═╝ ╩ ╚═╝ ╚═╝╚═╝ ╩ ╩ ╩╝╚╝╚═╝╚═╝ +- industryName: Remote settings + description: Configure agent options remotely, over the air. (Includes osquery config, and osquery startup flags.). + documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration + moreInfoUrl: https://github.com/fleetdm/fleet/issues/13825 + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + usualDepartment: Security +# +# ╔╦╗╔═╗╔═╗╔╦╗╔═╗ +# ║ ║╣ ╠═╣║║║╚═╗ +# ╩ ╚═╝╩ ╩╩ ╩╚═╝ +- industryName: Teams + friendlyName: Manage different endpoints differently + documentationUrl: https://fleetdm.com/docs/using-fleet/segment-hosts + description: Teams are what Fleet calls baselines, kinda like security groups or images. Every host in a team matches the same baseline, with minor exceptions. This makes it faster and less risky to maintain computers, leading to faster timelines and fewer tickets. #Enroll hosts into device groups ("teams") using different enrollment secrets and/or installers. Set baselines and strategies for hosts in different situations, and move hosts between them via API-driven automations or a simple, delegatable user interface with role-based access. tier: Premium jamfProHasFeature: yes jamfProtectHasFeature: yes + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Devices] + waysToUse: + - description: Automate remediation for different applications with different security postures (cloud security engineering) +# +# ╦ ╔═╗╔╗ ╔═╗╦ ╔═╗ +# ║ ╠═╣╠╩╗║╣ ║ ╚═╗ +# ╩═╝╩ ╩╚═╝╚═╝╩═╝╚═╝ +- industryName: Labels + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#add-label + friendlyName: Filter hosts using SQL + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Devices] usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Device management] + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no +# +# ╔═╗╔═╗╦ ╦╔═╗╦╔═╗╔═╗ +# ╠═╝║ ║║ ║║ ║║╣ ╚═╗ +# ╩ ╚═╝╩═╝╩╚═╝╩╚═╝╚═╝ +- industryName: Policies + description: A policy is a specific “yes” or “no” query. Use policies to manage security compliance in your organization. + documentationUrl: https://fleetdm.com/docs/using-fleet/fleet-ui + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: no + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Devices] + usualDepartment: IT + demos: + - description: A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers. + moreInfoUrl: https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6 + waysToUse: + - description: Trigger a workflow based on a failing policy + moreInfoUrl: https://fleetdm.com/docs/using-fleet/automations#policy-automations +# +# ╔═╗╦╦ ╔═╗ ╦╔╗╔╔╦╗╔═╗╔═╗╦═╗╦╔╦╗╦ ╦ ╔╦╗╔═╗╔╗╔╦╔╦╗╔═╗╦═╗╦╔╗╔╔═╗ +# ╠╣ ║║ ║╣ ║║║║ ║ ║╣ ║ ╦╠╦╝║ ║ ╚╦╝ ║║║║ ║║║║║ ║ ║ ║╠╦╝║║║║║ ╦ +# ╚ ╩╩═╝╚═╝ ╩╝╚╝ ╩ ╚═╝╚═╝╩╚═╩ ╩ ╩ ╩ ╩╚═╝╝╚╝╩ ╩ ╚═╝╩╚═╩╝╚╝╚═╝ +- industryName: File integrity monitoring (FIM) # Short industry phrase + friendlyName: Detect changes to critical files # Short, Fleet one-liner for the feature, written in the imperative mood. (If easy to do, base this off of the words that an actual customer is saying.) + description: Specify files to monitor for changes or deletions, then log those events to your SIEM or data lake, including key information such as filepath and checksum. # Clear Mr. Rogers description + documentationUrl: https://fleetdm.com/guides/osquery-evented-tables-overview#file-integrity-monitoring-fim # URL of the single-best page within the docs which serves as a "jumping-off point" for this feature. + screenshotSrc: "" # A screenshot of the single, best, simplifying, obvious example + tier: Free # Either "Free" or "Premium" + jamfProHasFeature: no + jamfProtectHasFeature: yes + usualDepartment: Security # or omit if there isn't a particular departmental leaning we've noticed + productCategories: [Endpoint operations] # or omit if this isn't associated with a single product category + pricingTableCategories: [Devices] + dri: mikermcneil #GitHub user name + demos: + - description: A top gaming company needed a way to monitor critical files on production Debian servers. + quote: The FIM features are kind of a top priority. + moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit + buzzwords: [File integrity monitoring (FIM),Host-based intrusion detection system (HIDS),Anomaly detection] + waysToUse: + - description: Monitor critical files on production Debian servers + - description: Detect anomalous filesystem activity + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Detect unintended changes + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Verify update status and monitor system health + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring + - description: Meet compliance mandates + moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring +# +# ╔═╗╦╦ ╔═╗ ╔═╗╔═╗╦═╗╦ ╦╦╔╗╔╔═╗ +# ╠╣ ║║ ║╣ ║ ╠═╣╠╦╝╚╗╔╝║║║║║ ╦ +# ╚ ╩╩═╝╚═╝ ╚═╝╩ ╩╩╚═ ╚╝ ╩╝╚╝╚═╝ +- industryName: File carving + description: Write the results of complex queries to AWS S3. + documentationUrl: https://fleetdm.com/docs/configuration/fleet-server-configuration#s-3-file-carving-backend + tier: Free + jamfProHasFeature: no + jamfProtectHasFeature: no + usualDepartment: Security + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] +# +# ╔╗ ╦╔╗╔╔═╗╦═╗╦ ╦ ╔═╗╦ ╦╔╦╗╦ ╦╔═╗╦═╗╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ +# ╠╩╗║║║║╠═╣╠╦╝╚╦╝ ╠═╣║ ║ ║ ╠═╣║ ║╠╦╝║╔═╝╠═╣ ║ ║║ ║║║║ +# ╚═╝╩╝╚╝╩ ╩╩╚═ ╩ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝╩╚═╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ +- industryName: Binary authorization + friendlyName: Restrict what programs can run, and what files running programs can access. + description: + documentationUrl: + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + dri: mikermcneil + usualDepartment: Security + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + comingSoonOn: 2025-06-30 + buzzwords: [Mandatory Access Control (MAC),Privilege confinement,Binary authorization,Santa,Binary allowlisting,Binary whitelisting] + demos: + - description: + moreInfoUrl: + waysToUse: + - description: Confine programs to a limited set of resources. + - description: Report on AppArmor events + moreInfoUrl: https://fleetdm.com/tables/apparmor_events + - description: Confine programs according to a set of rules that specify which files a program can access. + moreInfoUrl: https://wiki.debian.org/AppArmor + - description: Proactively protect the system against both known and unknown vulnerabilities. +# +# ╦═╗╔═╗╔═╗╔═╗╦═╗╔╦╗╦╔╗╔╔═╗ +# ╠╦╝║╣ ╠═╝║ ║╠╦╝ ║ ║║║║║ ╦ +# ╩╚═╚═╝╩ ╚═╝╩╚═ ╩ ╩╝╚╝╚═╝ +- industryName: Reporting + description: Generate reports based on searchable device attributes + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#get-query-report + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Devices] + usualDepartment: IT + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╦╔╗╔╔═╗╦╔╦╗╔═╗╔╗╔╔╦╗ ╦═╗╔═╗╔═╗╔═╗╔═╗╔╗╔╔═╗╔═╗ +# ║║║║║ ║ ║║║╣ ║║║ ║ ╠╦╝║╣ ╚═╗╠═╝║ ║║║║╚═╗║╣ +# ╩╝╚╝╚═╝╩═╩╝╚═╝╝╚╝ ╩ ╩╚═╚═╝╚═╝╩ ╚═╝╝╚╝╚═╝╚═╝ +- industryName: Incident response + 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: https://fleetdm.com/securing/how-osquery-can-help-cyber-responders#simplifying-endpoint-visibility-with-osquery-and-fleet + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + dri: mikermcneil + usualDepartment: Security + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + buzzwords: [] + demos: + - description: + moreInfoUrl: + waysToUse: + - description: +# +# ╔═╗╦ ╦╔═╗╔╦╗╔═╗╔╦╗ ╦ ╔═╗╔═╗╔═╗╦╔╗╔╔═╗ +# ║ ║ ║╚═╗ ║ ║ ║║║║ ║ ║ ║║ ╦║ ╦║║║║║ ╦ +# ╚═╝╚═╝╚═╝ ╩ ╚═╝╩ ╩ ╩═╝╚═╝╚═╝╚═╝╩╝╚╝╚═╝ +- industryName: Custom logging + description: Flexible, configurable logging destinations (AWS Kinesis, Lambda, GCP, Kafka). + documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations#log-destinations + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + usualDepartment: Security + productCategories: [Endpoint operations] + pricingTableCategories: [Devices] + buzzwords: [Real-time export,Ship logs] +# # # ██╗ ██╗██╗ ██╗██╗ ███╗ ██╗███████╗██████╗ █████╗ ██████╗ ██╗██╗ ██╗████████╗██╗ ██╗ # ██║ ██║██║ ██║██║ ████╗ ██║██╔════╝██╔══██╗██╔══██╗██╔══██╗██║██║ ██║╚══██╔══╝╚██╗ ██╔╝ @@ -358,7 +927,7 @@ dri: mikermcneil usualDepartment: Security productCategories: [Endpoint operations,Vulnerability management] - pricingTableCategories: [Vulnerability management] + pricingTableCategories: [Devices] buzzwords: [YARA scanning,Cyber Threat Intelligence (CTI),Indicators of compromise (IOCs),Antivirus (AV),Endpoint protection platform (EPP),Endpoint detection and response (EDR),Malware detection,Signature-based malware detection,Malware scanning,Malware analysis,Anomaly detection] demos: - description: A top media company used Fleet policies with YARA rules to continuously scan host filesystems for malware signatures provided by internal and external threat intelligence teams. @@ -388,7 +957,7 @@ friendlyName: Detect vulnerable software documentationUrl: https://fleetdm.com/vulnerability-management productCategories: [Vulnerability management] - pricingTableCategories: [Vulnerability management] + pricingTableCategories: [Devices] usualDepartment: Security tier: Free jamfProHasFeature: no @@ -408,36 +977,18 @@ - description: One of the world's largest, top transportation companies uses Fleet's API to email relevant, actually-installed vulnerabilities to responsible teams so they can fix them. moreInfoUrl: https://docs.google.com/document/d/1oeCmT077o_5nxzLhnxs7kcg_4Qn1Pn1F5zx10nQOAp8/edit # -# ╦ ╦╦ ╦╦ ╔╗╔╔═╗╦═╗╔═╗╔╗ ╦╦ ╦╔╦╗╦ ╦ ╔╦╗╔═╗╔═╗╦ ╦╔╗ ╔═╗╔═╗╦═╗╔╦╗ -# ╚╗╔╝║ ║║ ║║║║╣ ╠╦╝╠═╣╠╩╗║║ ║ ║ ╚╦╝ ║║╠═╣╚═╗╠═╣╠╩╗║ ║╠═╣╠╦╝ ║║ -# ╚╝ ╚═╝╩═╝╝╚╝╚═╝╩╚═╩ ╩╚═╝╩╩═╝╩ ╩ ╩ ═╩╝╩ ╩╚═╝╩ ╩╚═╝╚═╝╩ ╩╩╚══╩╝ -- industryName: Vulnerability dashboard - friendlyName: Vulnerability dashboard - documentationUrl: https://fleetdm.com/vulnerability-management - productCategories: [Vulnerability management] - pricingTableCategories: [Vulnerability management] - usualDepartment: Security - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: yes - demos: - - description: See a list of all vulnerabilities across your hosts. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/15919 - - description: AI generated CVSS v4 context. Coming soon (2024-12-31). - waysToUse: - - description: Easily communicate to executives regarding the progress of patching vulnerable software. Only show vulnerabilities that you care about. -# # ╦ ╦╦ ╦╦ ╔╗╔╔═╗╦═╗╔═╗╔╗ ╦╦ ╦╔╦╗╦ ╦ ╔═╗╔═╗╔═╗╦═╗╔═╗╔═╗ ╔═╗╔═╗╔═╗╔═╗ ╔═╗╔╗╔╔╦╗ ╔═╗╦ ╦╔═╗╔═╗ # ╚╗╔╝║ ║║ ║║║║╣ ╠╦╝╠═╣╠╩╗║║ ║ ║ ╚╦╝ ╚═╗║ ║ ║╠╦╝║╣ ╚═╗ ─── ║╣ ╠═╝╚═╗╚═╗ ╠═╣║║║ ║║ ║ ╚╗╔╝╚═╗╚═╗ # ╚╝ ╚═╝╩═╝╝╚╝╚═╝╩╚═╩ ╩╚═╝╩╩═╝╩ ╩ ╩ ╚═╝╚═╝╚═╝╩╚═╚═╝╚═╝ ╚═╝╩ ╚═╝╚═╝ ╩ ╩╝╚╝═╩╝ ╚═╝ ╚╝ ╚═╝╚═╝ -- industryName: Vulnerability scores (EPSS and CVSS) +- industryName: Vulnerability scores + friendlyName: EPSS and CVSS documentationUrl: https://fleetdm.com/vulnerability-management tier: Premium jamfProHasFeature: no jamfProtectHasFeature: yes usualDepartment: Security productCategories: [Vulnerability management] - pricingTableCategories: [Vulnerability management] + pricingTableCategories: [Devices] buzzwords: [Risk scores,Cyber risk,Risk reduction,Security operations effectiveness,Peer benchmarking,Security program effectiveness,Risk-based exposure scoring,Threat context,Cyber exposure,Exposure quantification and benchmarking,Optimize security investments,Vulnerability assessment] demos: - description: Fleet enables a more modern, threat-first prioritization approach to vulnerability management. @@ -452,14 +1003,15 @@ # ╔═╗╦╔═╗╔═╗ ╦╔═╔═╗╦ ╦╔═╗ # ║ ║╚═╗╠═╣ ╠╩╗║╣ ╚╗╔╝╚═╗ # ╚═╝╩╚═╝╩ ╩ ╩ ╩╚═╝ ╚╝ ╚═╝ -- industryName: CISA KEVs (known exploited vulnerabilities) +- industryName: CISA KEVs + description: Known exploited vulnerabilities documentationUrl: https://fleetdm.com/vulnerability-management tier: Premium jamfProHasFeature: no jamfProtectHasFeature: yes usualDepartment: Security productCategories: [Vulnerability management] - pricingTableCategories: [Vulnerability management] + pricingTableCategories: [Devices] demos: - description: moreInfoUrl: @@ -468,721 +1020,159 @@ - description: Use CISA KEVs for vulnerability management - moreInfoUrl: https://www.youtube.com/watch?v=Z3mw2oxssYk # -# ╔═╗╔═╗╔═╗╔═╗╔╦╗ ╔╦╗╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦═╗╦ ╦ -# ╠═╣╚═╗╚═╗║╣ ║ ║║║╚═╗║ ║ ║╚╗╔╝║╣ ╠╦╝╚╦╝ -# ╩ ╩╚═╝╚═╝╚═╝ ╩ ═╩╝╩╚═╝╚═╝╚═╝ ╚╝ ╚═╝╩╚═ ╩ +# ╔═╗╔═╗╔═╗╔═╗╔╦╗ ╔╦╗╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦═╗╦ ╦ +# ╠═╣╚═╗╚═╗║╣ ║ ║║║╚═╗║ ║ ║╚╗╔╝║╣ ╠╦╝╚╦╝ +# ╩ ╩╚═╝╚═╝╚═╝ ╩ ═╩╝╩╚═╝╚═╝╚═╝ ╚╝ ╚═╝╩╚═ ╩ - industryName: Asset discovery documentationUrl: tier: Premium comingSoonOn: 2025-06-30 usualDepartment: Security productCategories: [Vulnerability management] - pricingTableCategories: [Vulnerability management] + pricingTableCategories: [Devices] +# # -# ███████╗███╗ ██╗██████╗ ██████╗ ██████╗ ██╗███╗ ██╗████████╗ -# ██╔════╝████╗ ██║██╔══██╗██╔══██╗██╔═══██╗██║████╗ ██║╚══██╔══╝ -# █████╗ ██╔██╗ ██║██║ ██║██████╔╝██║ ██║██║██╔██╗ ██║ ██║ -# ██╔══╝ ██║╚██╗██║██║ ██║██╔═══╝ ██║ ██║██║██║╚██╗██║ ██║ -# ███████╗██║ ╚████║██████╔╝██║ ╚██████╔╝██║██║ ╚████║ ██║ -# ╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ -# -# ██████╗ ██████╗ ███████╗██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗ -# ██╔═══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝ -# ██║ ██║██████╔╝█████╗ ██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║███████╗ -# ██║ ██║██╔═══╝ ██╔══╝ ██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║╚════██║ -# ╚██████╔╝██║ ███████╗██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████║ -# ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ -# # -# ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦ ╦╔═╗╔═╗╦ ╔╦╗╦ ╦ -# ║║║╣ ╚╗╔╝║║ ║╣ ╠═╣║╣ ╠═╣║ ║ ╠═╣ -# ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩ ╩╚═╝╩ ╩╩═╝╩ ╩ ╩ -- industryName: Device health - friendlyName: Automate device health - description: Automatically report system health issues using webhooks or integrations, to notify or quarantine outdated or misconfigured systems that are at higher risk of vulnerabilities or theft. +# ██╗███╗ ██╗████████╗███████╗ ██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗ +# ██║████╗ ██║╚══██╔══╝██╔════╝██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝ +# ██║██╔██╗ ██║ ██║ █████╗ ██║ ███╗██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║███████╗ +# ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██║██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║╚════██║ +# ██║██║ ╚████║ ██║ ███████╗╚██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████║ +# ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ +# +# +# ╦═╗╔═╗╔═╗╔╦╗ ╔═╗╔═╗╦ +# ╠╦╝║╣ ╚═╗ ║ ╠═╣╠═╝║ +# ╩╚═╚═╝╚═╝ ╩ ╩ ╩╩ ╩ +- industryName: REST API + friendlyName: Automate any feature + description: + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Integrations] + usualDepartment: IT + documentationUrl: https://fleetdm.com/docs/rest-api/rest-api + screenshotSrc: + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes + dri: rachaelshaw +# +# ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗ +# ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗ +# ╚╩╝╚═╝╚═╝╩ ╩╚═╝╚═╝╩ ╩╚═╝ +- industryName: Webhooks + friendlyName: Automations documentationUrl: https://fleetdm.com/docs/using-fleet/automations#automations - screenshotSrc: - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: yes - productCategories: [Device management,Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - dri: mikermcneil - demos: - - description: A large tech company used the Fleet API to block access to corporate apps for outdated operating system versions with certain "celebrity" vulnerabilities. - quote: - moreInfoUrl: https://play.goconsensus.com/s4e490bb9 - buzzwords: [Device trust,Zero trust,Layer 7 device trust,Beyondcorp,Device attestation,Conditional access] - waysToUse: - - description: Automatically manage the behavior of endpoints that are at higher risk of vulnerabilities or data loss due to their configuration or patch level. - - description: Block access to corporate apps for users whose devices with unexpected settings, like disabled screen lock, passwords that are too short, unencrypted hard disks, and more - - description: Quickly implement conditional access based on device health using osquery and a simple device health REST API. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/14920 - - description: Control and restore access to applications by automatically restricting access when devices do not meet particular security requirements. - moreInfoUrl: https://duo.com/docs/device-health - - description: Control which laptop and desktop devices can access corporate apps and websites based on what vulnerabilities it might be exposed to based on how the device is configured, whether it's up to date, its MDM enrollment status, and anything else you can build in a SQL query of Fleet's 300 data tables representing information about enrolled host systems. Coming soon (2024-09-30). - moreInfoUrl: https://github.com/fleetdm/fleet/issues/16236 - - description: Implement multivariate device trust - moreInfoUrl: https://youtu.be/5sFOdpMLXQg?feature=shared&t=1445 - - description: Implement your own version of Google's zero trust model (BeyondCorp) - moreInfoUrl: https://cloud.google.com/beyondcorp - - description: Get endpoint data into ServiceNow and make your asset management teams happy - moreInfoUrl: https://www.youtube.com/watch?v=aVbU6_9JoM0 -# -# ╔═╗╦ ╦╔╦╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗ ╔═╗╔═╗╔═╗╔╦╗╦ ╦╦═╗╔═╗ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ -# ╠═╣║ ║ ║ ║ ║║║║╠═╣ ║ ║║ ╠═╝║ ║╚═╗ ║ ║ ║╠╦╝║╣ ╠═╣╚═╗╚═╗║╣ ╚═╗╚═╗║║║║╣ ║║║ ║ -# ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩ ╩ ╩ ╩╚═╝ ╩ ╚═╝╚═╝ ╩ ╚═╝╩╚═╚═╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ -- industryName: Automatic posture assessment - friendlyName: Verify any security or compliance goal - description: Simplify security audits, build definitive reports, and discover + verify ongoing compliance for every endpoint, from workstations to data centers. - documentationUrl: https://fleetdm.com/docs/using-fleet/cis-benchmarks#cis-benchmarks - screenshotSrc: - usualDepartment: Security - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: yes - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - dri: mikermcneil - demos: - - description: A large tech company used Fleet's CIS Benchmark policies to automatically assess posuture of 80,000 endpoints. - quote: - moreInfoUrl: - buzzwords: [Attack surface management (ASM),Endpoint hardening,Security posture,Cyber hygiene,Anomaly detection,Configuration management,Attack Surface Monitoring,Policy assessment] - waysToUse: - - description: Monitor devices that don't meet your organization's custom security policies - - description: Quickly report your posture and vulnerabilities to auditors, showing remediation status and timing. - - description: Keep your devices compliant with customizable baselines, or use common benchmarks like CIS. - - description: Discover security misconfigurations that increase attack surface. - - description: Detect suspcious services listening on open ports that should not be connected to the internet, such as Remote Desktop Protocol (RDP). - moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20statename%20%3D%20%E2%80%9CEnabled%E2%80%9D-,OPEN%20SOCKETS,-Lastly%2C%20an%20examination - - description: Discover potentially unwanted programs that increase attack surface. - moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/ - - description: Detect self-signed certifcates - - description: Detect legacy protocols with safer versions - moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20self_signed%20%3D%201%3B-,LEGACY%20PROTOCOLS,-This%20section%20will - - description: Detect exposed secrets on the command line - moreInfoUrl: https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WDigest%20is%20disabled.-,EXPOSED%20SECRETS,-Often%2C%20to%20create - - description: Detect and surface issues with devices - - description: Share device health reports - - description: Align endpoints with your security policies - moreInfoUrl: https://www.axonius.com/use-cases/cmdb-reconciliation - - description: Maximize security control coverage - - description: Uncover gaps in security policies, configurations, and hygiene - moreInfoUrl: https://www.axonius.com/use-cases/coverage-gap-discovery - - description: Automatically apply security policies to protect endpoints against attack. - - description: Surface security issues in all your deployed endpoints even data centers and factories. - - description: Continually validate controls and policies -# -# ╦ ╦╦ ╦╔╦╗╔═╗╔╗╔ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗ ╔╦╗╔═╗╔═╗╔═╗╦╔╗╔╔═╗ -# ╠═╣║ ║║║║╠═╣║║║───║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ║║║╠═╣╠═╝╠═╝║║║║║ ╦ -# ╩ ╩╚═╝╩ ╩╩ ╩╝╚╝ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╩ ╩╩ ╩╩ ╩ ╩╝╚╝╚═╝ -- industryName: Human-endpoint mapping - friendlyName: See who logs in on every computer - description: Identify who logs in to any system, including login history and current sessions. Look up any host by the email address of the person using it. - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#get-hosts-google-chrome-profiles - screenshotSrc: - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - buzzwords: [Device users,human-to-device mapping] - dri: mikermcneil - demos: - - description: Security engineers at a top gaming company wanted to get demographics off their macOS, Windows, and Linux machines about who the user is and who's logged in. - moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit - - description: Data engineers at a top biotech corporation needed to know who is logged into their devices. - quote: So we don't know exactly what's going on after we deploy the device, we know that they are compliant with the security because we are running these stuff, but we don't know certainly who is running, who is logging in the device? - moreInfoUrl: https://docs.google.com/document/d/17MNI5ykzlFjdVmQ8SPMrT1oR_hY_vkYAJx31F7l7Pv8/edit#heading=h.7en766pueek4 - waysToUse: - - description: Look up computer by ActiveDirectory account - - description: Find device by Google Chrome user - - description: Identify who logs in to any system, including login history and current sessions. - - description: Look up any host by the email address of the person using it. - - description: Check user login history - moreInfoUrl: https://www.lepide.com/how-to/audit-who-logged-into-a-computer-and-when.html#:~:text=To%20find%20out%20the%20details,logs%20in%20%E2%80%9CWindows%20Logs%E2%80%9D. - - description: See currently logged in users - moreInfoUrl: https://www.top-password.com/blog/see-currently-logged-in-users-in-windows/ - - description: Get demographics off of our machines about who the user is and who's logged in - moreInfoUrl: https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit - - description: See what servers someone is logged-in on - moreInfoUrl: https://community.spiceworks.com/topic/138171-is-there-a-way-to-see-what-servers-someone-is-logged-in-on -# -# ╦╔╗╔╔╦╗╦═╗╦ ╦╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ -# ║║║║ ║ ╠╦╝║ ║╚═╗ ║ ║║ ║║║║ ║║║╣ ║ ║╣ ║ ║ ║║ ║║║║ -# ╩╝╚╝ ╩ ╩╚═╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ -- industryName: Intrusion detection - friendlyName: Build custom query and policy automations to detect suspicious behavior - description: Send webhooks and ship logs to detect intrusions and issues with devices. - documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: yes - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - buzzwords: [Host-based intrusion detection system (HIDS,Indicators of Compromise (IOCs),Feeder for SIEM] - demos: - - description: A top media company wanted to share more security data with other departments without slowing down hosts. - waysToUse: - - description: Send webhooks to generate alerts when an IOC is detected on one or more devices. - - description: Ship logs to Splunk, Snowflake, and other SIEMs to build a host-based intrusion detection system (HIDS). - - description: Synchronize live state of endpoints to a data lake or SIEM in a consistent shape. - - description: Export the data to other systems - moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit - - description: Export data to a third-party SIEM tool - moreInfoUrl: https://www.websense.com/content/support/library/web/hosted/admin_guide/siem_integration_explain.aspx - - description: Gather data and log events from endpoints - moreInfoUrl: https://techbeacon.com/security/how-osquery-can-lift-your-security-teams-game#:~:text=%22If%20security%20teams%20didn%27t%20have%20osquery%2C%20they%20would%20have%20to%20find%20a%20way%20to%20manually%20go%20into%20each%20endpoint%20and%20gather%20data%2C%20or%20buy%20a%20third%2Dparty%20tool%20to%20do%20that%20for%20them -# -# ╔═╗╦ ╔═╗╔═╗╔╗╔╔═╗╦═╗╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╔═╗╔═╗╦═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ -# ╠═╣║───║ ╦║╣ ║║║║╣ ╠╦╝╠═╣ ║ ║╣ ║║ ║║║╣ ╚═╗║ ╠╦╝║╠═╝ ║ ║║ ║║║║╚═╗ -# ╩ ╩╩ ╚═╝╚═╝╝╚╝╚═╝╩╚═╩ ╩ ╩ ╚═╝═╩╝ ═╩╝╚═╝╚═╝╚═╝╩╚═╩╩ ╩ ╩╚═╝╝╚╝╚═╝ -- industryName: AI-generated descriptions (optional) - description: Optionally use AI to explain why your security policies matter. - documentationUrl: https://github.com/fleetdm/fleet/issues/18187 - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] -# -# ╔═╗╦╔╦╗ -# ╠╣ ║║║║ -# ╚ ╩╩ ╩ -- industryName: File integrity monitoring (FIM) # Short industry phrase - friendlyName: Detect changes to critical files # Short, Fleet one-liner for the feature, written in the imperative mood. (If easy to do, base this off of the words that an actual customer is saying.) - description: Specify files to monitor for changes or deletions, then log those events to your SIEM or data lake, including key information such as filepath and checksum. # Clear Mr. Rogers description - documentationUrl: https://fleetdm.com/guides/osquery-evented-tables-overview#file-integrity-monitoring-fim # URL of the single-best page within the docs which serves as a "jumping-off point" for this feature. - screenshotSrc: "" # A screenshot of the single, best, simplifying, obvious example - tier: Free # Either "Free" or "Premium" - jamfProHasFeature: no - jamfProtectHasFeature: yes - usualDepartment: Security # or omit if there isn't a particular departmental leaning we've noticed - productCategories: [Endpoint operations] # or omit if this isn't associated with a single product category - pricingTableCategories: [Endpoint operations] - dri: mikermcneil #GitHub user name - demos: - - description: A top gaming company needed a way to monitor critical files on production Debian servers. - quote: The FIM features are kind of a top priority. - moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit - buzzwords: [File integrity monitoring (FIM),Host-based intrusion detection system (HIDS),Anomaly detection] - waysToUse: - - description: Monitor critical files on production Debian servers - - description: Detect anomalous filesystem activity - moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring - - description: Detect unintended changes - moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring - - description: Verify update status and monitor system health - moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring - - description: Meet compliance mandates - moreInfoUrl: https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring -# -# ╔╦╗╔═╗╦╔╗╔╔╦╗╔═╗╔╗╔╔═╗╔╗╔╔═╗╔═╗ ╦ ╦╦╔╗╔╔╦╗╔═╗╦ ╦╔═╗ -# ║║║╠═╣║║║║ ║ ║╣ ║║║╠═╣║║║║ ║╣ ║║║║║║║ ║║║ ║║║║╚═╗ -# ╩ ╩╩ ╩╩╝╚╝ ╩ ╚═╝╝╚╝╩ ╩╝╚╝╚═╝╚═╝ ╚╩╝╩╝╚╝═╩╝╚═╝╚╩╝╚═╝ -- industryName: Maintenance windows - friendlyName: Fleet in your calendar - description: Create a calendar event to auto-remediate failing policies when your end users are free. - documentationUrl: https://github.com/fleetdm/fleet/issues/17230 - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: no - isExperimental: yes - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] -# -# ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔═╗╔╗╔╔═╗╦╔╗╔╔═╗╔═╗╦═╗╦╔╗╔╔═╗ -# ║║║╣ ║ ║╣ ║ ║ ║║ ║║║║ ║╣ ║║║║ ╦║║║║║╣ ║╣ ╠╦╝║║║║║ ╦ -# ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ ╚═╝╝╚╝╚═╝╩╝╚╝╚═╝╚═╝╩╚═╩╝╚╝╚═╝ -- industryName: Detection engineering - friendlyName: # Ship logs to your data lake and comopare with known bad binary hashes or capture behavioral data and build custom detections (e.g. using a framework like MITRE) - description: - documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: yes - dri: mikermcneil - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - buzzwords: [Security analytics,Behavioral analytics,MITRE ATT&CK,Tactics techniques and procedures (TTPs),Security information and event management (SIEM)] - demos: - - description: - moreInfoUrl: - waysToUse: - - description: -# -# ╔╦╗╦ ╦╦═╗╔═╗╔═╗╔╦╗ ╦ ╦╦ ╦╔╗╔╔╦╗╦╔╗╔╔═╗ -# ║ ╠═╣╠╦╝║╣ ╠═╣ ║ ╠═╣║ ║║║║ ║ ║║║║║ ╦ -# ╩ ╩ ╩╩╚═╚═╝╩ ╩ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╝╚╝╚═╝ -- industryName: Threat hunting - friendlyName: # TODO: live query - description: - documentationUrl: https://fleetdm.com/queries - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: yes - dri: mikermcneil - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - buzzwords: [] - demos: - - description: - moreInfoUrl: - waysToUse: - - description: -# -# ╦╔╗╔╔═╗╦╔╦╗╔═╗╔╗╔╔╦╗ ╦═╗╔═╗╔═╗╔═╗╔═╗╔╗╔╔═╗╔═╗ -# ║║║║║ ║ ║║║╣ ║║║ ║ ╠╦╝║╣ ╚═╗╠═╝║ ║║║║╚═╗║╣ -# ╩╝╚╝╚═╝╩═╩╝╚═╝╝╚╝ ╩ ╩╚═╚═╝╚═╝╩ ╚═╝╝╚╝╚═╝╚═╝ -- industryName: Incident response - 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: https://fleetdm.com/securing/how-osquery-can-help-cyber-responders#simplifying-endpoint-visibility-with-osquery-and-fleet - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - dri: mikermcneil - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - buzzwords: [] - demos: - - description: - moreInfoUrl: - waysToUse: - - description: -# -# ╔╗ ╦╔╗╔╔═╗╦═╗╦ ╦ ╔═╗╦ ╦╔╦╗╦ ╦╔═╗╦═╗╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ -# ╠╩╗║║║║╠═╣╠╦╝╚╦╝ ╠═╣║ ║ ║ ╠═╣║ ║╠╦╝║╔═╝╠═╣ ║ ║║ ║║║║ -# ╚═╝╩╝╚╝╩ ╩╩╚═ ╩ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝╩╚═╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ -- industryName: Binary authorization - friendlyName: Restrict what programs can run, and what files running programs can access. - description: - documentationUrl: - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - dri: mikermcneil - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - comingSoonOn: 2025-06-30 - buzzwords: [Mandatory Access Control (MAC),Privilege confinement,Binary authorization,Santa,Binary allowlisting,Binary whitelisting] - demos: - - description: - moreInfoUrl: - waysToUse: - - description: Confine programs to a limited set of resources. - - description: Report on AppArmor events - moreInfoUrl: https://fleetdm.com/tables/apparmor_events - - description: Confine programs according to a set of rules that specify which files a program can access. - moreInfoUrl: https://wiki.debian.org/AppArmor - - description: Proactively protect the system against both known and unknown vulnerabilities. -# -# ╔═╗╔═╗╔═╗╔╗╔╔╦╗ ╔═╗╦ ╦╔╦╗╔═╗ ╦ ╦╔═╗╔╦╗╔═╗╔╦╗╔═╗ -# ╠═╣║ ╦║╣ ║║║ ║ ╠═╣║ ║ ║ ║ ║───║ ║╠═╝ ║║╠═╣ ║ ║╣ -# ╩ ╩╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩ ╚═╝ ╚═╝╩ ═╩╝╩ ╩ ╩ ╚═╝ -- industryName: Agent auto-update (optional) - friendlyName: Keep agents and extensions up to date - description: Optionally keep agents and extensions up to date automatically using Fleet's free update registry, powered by The Update Framework (TUF). - documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT -# -# ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╦═╗╔═╗ -# ║║║║╚═╗ ║ ╠═╣║ ║ ║╣ ╠╦╝╚═╗ -# ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╚═╝╩╚═╚═╝ -- industryName: Installers (self-service) - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts - waysToUse: - - description: Build scripts for Ansible deployments - moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 - - description: Deploy osquery to macOS via Jamf - moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 - - description: Package osquery for Linux servers via Workspace One and Windows servers via group policies - moreInfoUrl: https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4 -# -# ╔╗ ╔═╗╔╦╗╔═╗╦ ╦ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗╦╔═╗╔╗╔ -# ╠╩╗╠═╣ ║ ║ ╠═╣ ║║║║╚═╗ ║ ╠═╣║ ║ ╠═╣ ║ ║║ ║║║║ -# ╚═╝╩ ╩ ╩ ╚═╝╩ ╩ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╩ ╩ ╩ ╩╚═╝╝╚╝ -- industryName: Batch installation (Chef, Ansible, Puppet, MDM) - friendlyName: Install agents over the air - documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT -# -# ╦═╗╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╔═╗╔═╗╔╦╗╔╦╗╦╔╗╔╔═╗╔═╗ -# ╠╦╝║╣ ║║║║ ║ ║ ║╣ ╚═╗║╣ ║ ║ ║║║║║ ╦╚═╗ -# ╩╚═╚═╝╩ ╩╚═╝ ╩ ╚═╝ ╚═╝╚═╝ ╩ ╩ ╩╝╚╝╚═╝╚═╝ -- industryName: Remote settings - description: Configure agent options remotely, over the air. (Includes osquery config, and osquery startup flags.). - documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration - moreInfoUrl: https://github.com/fleetdm/fleet/issues/13825 - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: Security -# -# ╔╦╗╦═╗╦╔═╗╔═╗╔═╗╦═╗ ╔═╗ ╦ ╦╔═╗╦═╗╦╔═╔═╗╦ ╔═╗╦ ╦ ╔╗ ╔═╗╔═╗╔═╗╔╦╗ ╔═╗╔╗╔ ╔═╗ -# ║ ╠╦╝║║ ╦║ ╦║╣ ╠╦╝ ╠═╣ ║║║║ ║╠╦╝╠╩╗╠╣ ║ ║ ║║║║ ╠╩╗╠═╣╚═╗║╣ ║║ ║ ║║║║ ╠═╣ -# ╩ ╩╚═╩╚═╝╚═╝╚═╝╩╚═ ╩ ╩ ╚╩╝╚═╝╩╚═╩ ╩╚ ╩═╝╚═╝╚╩╝ ╚═╝╩ ╩╚═╝╚═╝═╩╝ ╚═╝╝╚╝ ╩ ╩ -# ╔═╗╔═╗╦╦ ╦╔╗╔╔═╗ ╔═╗╔═╗╦ ╦╔═╗╦ ╦ -# ╠╣ ╠═╣║║ ║║║║║ ╦ ╠═╝║ ║║ ║║ ╚╦╝ -# ╚ ╩ ╩╩╩═╝╩╝╚╝╚═╝ ╩ ╚═╝╩═╝╩╚═╝ ╩ -- industryName: Trigger a workflow based on a failing policy - documentationUrl: https://fleetdm.com/docs/using-fleet/automations#policy-automations - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no -# -# ╔═╗╔═╗╔═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ -# ╚═╗║ ║╠╣ ║ ║║║╠═╣╠╦╝║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ -# ╚═╝╚═╝╚ ╩ ╚╩╝╩ ╩╩╚═╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ -- industryName: Software inventory - documentationUrl: https://fleetdm.com/docs/get-started/anatomy#software-library - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - waysToUse: - - description: Implement software inventory recommendations from the SANS 20 / CIS 18. - moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 - - description: View a list of all software and their versions installed on all your hosts. - - description: View a list of software rolled up by title. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/14674 -# -# ╦ ╦╔═╗╦═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ -# ╠═╣╠═╣╠╦╝ ║║║║║╠═╣╠╦╝║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ -# ╩ ╩╩ ╩╩╚══╩╝╚╩╝╩ ╩╩╚═╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ -- industryName: Hardware inventory - documentationUrl: https://fleetdm.com/tables/system_info - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] + pricingTableCategories: [Integrations] + usualDepartment: IT tier: Free jamfProHasFeature: yes - jamfProtectHasFeature: no - waysToUse: - - description: Implement hardware and infrastructure inventory recommendations from the SANS 20 / CIS 18. - moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 + jamfProtectHasFeature: yes # -# ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ ╔╦╗╔═╗╔═╗╦ ╦╔╗ ╔═╗╔═╗╦═╗╔╦╗ -# ║║║╣ ╚╗╔╝║║ ║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ ║║╠═╣╚═╗╠═╣╠╩╗║ ║╠═╣╠╦╝ ║║ -# ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ ═╩╝╩ ╩╚═╝╩ ╩╚═╝╚═╝╩ ╩╩╚══╩╝ -- industryName: Device inventory dashboard - documentationUrl: - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Endpoint operations] +# ╔═╗╦═╗╔═╗╔╗╔╔╦╗ ╔═╗╔═╗╦ ╔═╗╔╗╔╦ ╦ ╦ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗ +# ║ ╦╠╦╝╠═╣║║║ ║ ╠═╣╠═╝║───║ ║║║║║ ╚╦╝ ╠═╣║ ║ ║╣ ╚═╗╚═╗ +# ╚═╝╩╚═╩ ╩╝╚╝ ╩ ╩ ╩╩ ╩ ╚═╝╝╚╝╩═╝╩ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ +- industryName: Grant API-only access + description: Grant API-only access to accounts exclusively for automation. + documentationUrl: https://fleetdm.com/docs/using-fleet/fleetctl-cli#using-fleetctl-with-an-api-only-user + productCategories: [Endpoint operations] + pricingTableCategories: [Integrations] + tier: Free + jamfProHasFeature: yes + jamfProtectHasFeature: yes +# +# ╔═╗╦╔╗╔╔═╗╦ ╔═╗ ╔═╗╦╔═╗╔╗╔ ╔═╗╔╗╔ +# ╚═╗║║║║║ ╦║ ║╣ ╚═╗║║ ╦║║║ ║ ║║║║ +# ╚═╝╩╝╚╝╚═╝╩═╝╚═╝ ╚═╝╩╚═╝╝╚╝ ╚═╝╝╚╝ +- industryName: Single sign on + description: SSO, SAML + documentationUrl: https://fleetdm.com/docs/deploy/single-sign-on-sso#single-sign-on-sso + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Integrations] usualDepartment: IT tier: Free jamfProHasFeature: yes jamfProtectHasFeature: yes # -# ╔╗ ╦═╗╔═╗╦ ╦╔═╗╔═╗ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗ ╔═╗╔═╗╔═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╔═╗╔═╗╔═╗╦╔═╔═╗╔═╗╔═╗╔═╗ -# ╠╩╗╠╦╝║ ║║║║╚═╗║╣ ║║║║╚═╗ ║ ╠═╣║ ║ ║╣ ║║ ╚═╗║ ║╠╣ ║ ║║║╠═╣╠╦╝║╣ ╠═╝╠═╣║ ╠╩╗╠═╣║ ╦║╣ ╚═╗ -# ╚═╝╩╚═╚═╝╚╩╝╚═╝╚═╝ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╚═╝═╩╝ ╚═╝╚═╝╚ ╩ ╚╩╝╩ ╩╩╚═╚═╝ ╩ ╩ ╩╚═╝╩ ╩╩ ╩╚═╝╚═╝╚═╝ -- industryName: Browse installed software packages - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#software +# ╦╦ ╦╔═╗╔╦╗ ╦╔╗╔ ╔╦╗╦╔╦╗╔═╗ ╔═╗╦═╗╔═╗╦ ╦╦╔═╗╦╔═╗╔╗╔╦╔╗╔╔═╗ +# ║║ ║╚═╗ ║───║║║║───║ ║║║║║╣ ╠═╝╠╦╝║ ║╚╗╔╝║╚═╗║║ ║║║║║║║║║ ╦ +# ╚╝╚═╝╚═╝ ╩ ╩╝╚╝ ╩ ╩╩ ╩╚═╝ ╩ ╩╚═╚═╝ ╚╝ ╩╚═╝╩╚═╝╝╚╝╩╝╚╝╚═╝ +- industryName: Automatic user creation (JIT, SCIM) + description: Auto-create and manipulate Fleet users from Okta, etc with just-in-time (JIT) provisioning. + documentationUrl: https://fleetdm.com/docs/deploy/single-sign-on-sso#just-in-time-jit-user-provisioning productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no -# -# ╔╦╗╦ ╦╔═╗ ╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗ ╔═╗╦ ╦╔╦╗╦ ╦╔═╗╔╗╔╔╦╗╦╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ -# ║ ║║║║ ║───╠╣ ╠═╣║ ║ ║ ║╠╦╝ ╠═╣║ ║ ║ ╠═╣║╣ ║║║ ║ ║║ ╠═╣ ║ ║║ ║║║║ -# ╩ ╚╩╝╚═╝ ╚ ╩ ╩╚═╝ ╩ ╚═╝╩╚═ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╚═╝╩ ╩ ╩ ╩╚═╝╝╚╝ -- industryName: Two-factor authentication - moreInfoUrl: https://github.com/fleetdm/fleet/issues/5478 - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - waysToUse: - - description: Enforce two-factor authentication when logging in to Fleet for added security. - comingSoonOn: 2024-12-31 #customer-rosner -# -# ╔═╗╦ ╦╔═╗╔╦╗╔═╗╔╦╗ ╔═╗╔═╗╦═╗ ╔═╗╦═╗╔═╗╔═╗╔═╗ ╔╦╗╔═╗╔╦╗╔═╗╦╔╗╔ ╦╔╦╗╔═╗╔╗╔╔╦╗╦╔╦╗╦ ╦ -# ╚═╗╚╦╝╚═╗ ║ ║╣ ║║║ ╠╣ ║ ║╠╦╝ ║ ╠╦╝║ ║╚═╗╚═╗───║║║ ║║║║╠═╣║║║║ ║ ║║║╣ ║║║ ║ ║ ║ ╚╦╝ -# ╚═╝ ╩ ╚═╝ ╩ ╚═╝╩ ╩ ╚ ╚═╝╩╚═ ╚═╝╩╚═╚═╝╚═╝╚═╝ ═╩╝╚═╝╩ ╩╩ ╩╩╝╚╝ ╩═╩╝╚═╝╝╚╝ ╩ ╩ ╩ ╩ -# ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ ╔═╗╦═╗╔═╗╦ ╦╦╔═╗╦╔═╗╔╗╔╦╔╗╔╔═╗ -# ║║║╠═╣║║║╠═╣║ ╦║╣ ║║║║╣ ║║║ ║ ╠═╝╠╦╝║ ║╚╗╔╝║╚═╗║║ ║║║║║║║║║ ╦ -# ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ ╩ ╩╚═╚═╝ ╚╝ ╩╚═╝╩╚═╝╝╚╝╩╝╚╝╚═╝ -- industryName: System for Cross-domain Identity Management (SCIM) provisioning - moreInfoUrl: https://github.com/fleetdm/fleet/issues/15671 - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] + pricingTableCategories: [Integrations] usualDepartment: IT tier: Premium - comingSoonOn: 2024-12-31 #customer-rosner -# -# ╔═╗╔═╗╔═╗╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗╔═╗ ╔╗ ╦ ╦ ╦╔═╗ ╔═╗╔═╗╦═╗╦╔═╗╦ -# ╚═╗║╣ ╠═╣╠╦╝║ ╠═╣ ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗ ╠╩╗╚╦╝ ║╠═╝ ╚═╗║╣ ╠╦╝║╠═╣║ -# ╚═╝╚═╝╩ ╩╩╚═╚═╝╩ ╩ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝╚═╝ ╚═╝ ╩ ╩╩┘ ╚═╝╚═╝╩╚═╩╩ ╩╩═╝┘ -# ╦ ╦╔═╗╔═╗╔╦╗╔╗╔╔═╗╔╦╗╔═╗ ╦ ╦╦ ╦╦╔╦╗ -# ╠═╣║ ║╚═╗ ║ ║║║╠═╣║║║║╣ ║ ║║ ║║ ║║ -# ╩ ╩╚═╝╚═╝ ╩ ╝╚╝╩ ╩╩ ╩╚═╝┘ ╚═╝╚═╝╩═╩╝ -- industryName: Search devices by IP, serial, hostname, UUID - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#hosts - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Endpoint operations] - tier: Free jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╦ ╔═╗╔╗ ╔═╗╦ ╔═╗ ╔═╗╔═╗ ╦ ╔╦╗╦═╗╦╦ ╦╔═╗╔╗╔ -# ║ ╠═╣╠╩╗║╣ ║ ╚═╗ ╚═╗║═╬╗║─────║║╠╦╝║╚╗╔╝║╣ ║║║ -# ╩═╝╩ ╩╚═╝╚═╝╩═╝╚═╝ ╚═╝╚═╝╚╩═╝ ═╩╝╩╚═╩ ╚╝ ╚═╝╝╚╝ -- industryName: Labels (SQL-driven) - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#add-label - friendlyName: Filter hosts using SQL - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Free - jamfProHasFeature: no jamfProtectHasFeature: no -# -# ╦ ╦╔═╗╦═╗╔═╗╦╔═╗╔╗╔╔═╗╔╗ ╦ ╔═╗ ╔═╗ ╦ ╦╔═╗╦═╗╦╔═╗╔═╗ ╔═╗╔╗╔╔╦╗ ╔═╗╔═╗╔╗╔╔═╗╦╔═╗ -# ╚╗╔╝║╣ ╠╦╝╚═╗║║ ║║║║╠═╣╠╩╗║ ║╣ ║═╬╗║ ║║╣ ╠╦╝║║╣ ╚═╗ ╠═╣║║║ ║║ ║ ║ ║║║║╠╣ ║║ ╦ -# ╚╝ ╚═╝╩╚═╚═╝╩╚═╝╝╚╝╩ ╩╚═╝╩═╝╚═╝ ╚═╝╚╚═╝╚═╝╩╚═╩╚═╝╚═╝ ╩ ╩╝╚╝═╩╝ ╚═╝╚═╝╝╚╝╚ ╩╚═╝ -# ╔═╗╦╔╦╗╔═╗╔═╗╔═╗ -# ║ ╦║ ║ ║ ║╠═╝╚═╗ -# ╚═╝╩ ╩ ╚═╝╩ ╚═╝ -- industryName: Versionable queries and config (GitOps) - documentationUrl: https://fleetdm.com/guides/using-github-actions-to-apply-configuration-profiles-with-fleet#basic-article - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - demos: - - description: A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers. - moreInfoUrl: https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6 -# -# ╔═╗╔═╗╔═╗╔═╗╔═╗ ╔╦╗╦═╗╔═╗╔╗╔╔═╗╔═╗╔═╗╦═╗╔═╗╔╗╔╔═╗╦ ╦ -# ╚═╗║ ║ ║╠═╝║╣ ║ ╠╦╝╠═╣║║║╚═╗╠═╝╠═╣╠╦╝║╣ ║║║║ ╚╦╝ -# ╚═╝╚═╝╚═╝╩ ╚═╝ ╩ ╩╚═╩ ╩╝╚╝╚═╝╩ ╩ ╩╩╚═╚═╝╝╚╝╚═╝ ╩ -- industryName: Scope transparency - tier: Free - documentationUrl: https://fleetdm.com/transparency - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] -# -# ╔═╗╦ ╦╔╦╗╦╔╦╗ ╦ ╔═╗╔═╗ ╔═╗╔═╗ ╔═╗╔═╗╔╦╗╦╦ ╦╦╔╦╗╦ ╦ -# ╠═╣║ ║ ║║║ ║ ║ ║ ║║ ╦ ║ ║╠╣ ╠═╣║ ║ ║╚╗╔╝║ ║ ╚╦╝ -# ╩ ╩╚═╝═╩╝╩ ╩ ╩═╝╚═╝╚═╝ ╚═╝╚ ╩ ╩╚═╝ ╩ ╩ ╚╝ ╩ ╩ ╩ -- industryName: Audit log of activity (queries, scripts, logins, etc) - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#list-activities - productCategories: [Endpoint operations, Device management] - pricingTableCategories: [Endpoint operations] - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: yes - usualDepartment: Security - waysToUse: - - description: Export activity of Fleet admins to your SIEM or data lake -# -# ╔═╗ ╦ ╦╔═╗╦═╗╦ ╦ ╔═╗╔═╗╦═╗╔═╗╔═╗╦═╗╔╦╗╔═╗╔╗╔╔═╗╔═╗ ╔╦╗╔═╗╔╗╔╦╔╦╗╔═╗╦═╗╦╔╗╔╔═╗ -# ║═╬╗║ ║║╣ ╠╦╝╚╦╝ ╠═╝║╣ ╠╦╝╠╣ ║ ║╠╦╝║║║╠═╣║║║║ ║╣ ║║║║ ║║║║║ ║ ║ ║╠╦╝║║║║║ ╦ -# ╚═╝╚╚═╝╚═╝╩╚═ ╩ ╩ ╚═╝╩╚═╚ ╚═╝╩╚═╩ ╩╩ ╩╝╚╝╚═╝╚═╝ ╩ ╩╚═╝╝╚╝╩ ╩ ╚═╝╩╚═╩╝╚╝╚═╝ -- industryName: Query performance monitoring - documentationUrl: https://fleetdm.com/docs/get-started/faq#will-fleet-slow-down-my-servers-what-about-my-employee-laptops - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - demos: - - description: A top software company needed to understand the performance impact of osquery queries before running them on all of their production Linux servers. - moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg - - description: A top software company wanted to detect regressions when adding/changing queries and fail builds if queries were too expensive. - moreInfoUrl: https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg - waysToUse: - - description: Monitor performance for automated queries. - - description: Monitor performance for live queries. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/467 -# -# ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗ ╔═╗╔╗╔╔╦╗ ╔═╗╦ ╦╦═╗╔═╗╔═╗╔═╗╔═╗ ╦╔═╗╔═╗╦ ╦╔═╗╔═╗ ╦ ╦╦╔╦╗╦ ╦ -# ║║║╣ ║ ║╣ ║ ║ ╠═╣║║║ ║║ ╚═╗║ ║╠╦╝╠╣ ╠═╣║ ║╣ ║╚═╗╚═╗║ ║║╣ ╚═╗ ║║║║ ║ ╠═╣ -# ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩ ╩╝╚╝═╩╝ ╚═╝╚═╝╩╚═╚ ╩ ╩╚═╝╚═╝ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ ╚╩╝╩ ╩ ╩ ╩ -# ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗╔═╗ -# ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗ -# ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝╚═╝ -- industryName: Detect and surface issues with devices (policies) - documentationUrl: https://fleetdm.com/docs/get-started/anatomy#policy - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╔═╗╦ ╔═╗═╗ ╦╦╔╗ ╦ ╔═╗ ╦ ╔═╗╔═╗ ╔╦╗╔═╗╔═╗╔╦╗╦╔╗╔╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ -# ╠╣ ║ ║╣ ╔╩╦╝║╠╩╗║ ║╣ ║ ║ ║║ ╦ ║║║╣ ╚═╗ ║ ║║║║╠═╣ ║ ║║ ║║║║╚═╗ -# ╚ ╩═╝╚═╝╩ ╚═╩╚═╝╩═╝╚═╝ ╩═╝╚═╝╚═╝ ═╩╝╚═╝╚═╝ ╩ ╩╝╚╝╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ -- industryName: Flexible log destinations (AWS Kinesis, Lambda, GCP, Kafka) - documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations#log-destinations - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - buzzwords: [Real-time export,Ship logs] -# -# ╔═╗╦╦ ╔═╗ ╔═╗╔═╗╦═╗╦ ╦╦╔╗╔╔═╗ -# ╠╣ ║║ ║╣ ║ ╠═╣╠╦╝╚╗╔╝║║║║║ ╦ -# ╚ ╩╩═╝╚═╝ ╚═╝╩ ╩╩╚═ ╚╝ ╩╝╚╝╚═╝ -- industryName: File carving (AWS S3) - documentationUrl: https://fleetdm.com/docs/configuration/fleet-server-configuration#s-3-file-carving-backend - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - usualDepartment: Security - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] + # System for Cross-domain Identity Management (SCIM) provisioning comingSoonOn: 2024-12-31 #customer-rosner, https://github.com/fleetdm/fleet/issues/15671 # -# ╦ ╦╔═╗╦═╗╦╔═╗╔╗ ╦ ╔═╗ ╔═╗╔╗╔╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╔╗╔╔╦╗ -# ╚╗╔╝╠═╣╠╦╝║╠═╣╠╩╗║ ║╣ ║╣ ║║║╠╦╝║ ║║ ║ ║║║║╣ ║║║ ║ -# ╚╝ ╩ ╩╩╚═╩╩ ╩╚═╝╩═╝╚═╝ ╚═╝╝╚╝╩╚═╚═╝╩═╝╩═╝╩ ╩╚═╝╝╚╝ ╩ -- industryName: Variable enrollment - description: Enroll hosts in different groups using different enrollment secrets and/or installers per-baseline. - documentationUrl: https://fleetdm.com/docs/using-fleet/segment-hosts - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: no - productCategories: [Endpoint operations, Device management] - pricingTableCategories: [Endpoint operations] +# ╔╦╗╦ ╦╦╦═╗╔╦╗ ╔═╗╔═╗╦═╗╔╦╗╦ ╦ ╔═╗╦ ╦╔╦╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗╔╗╔ +# ║ ╠═╣║╠╦╝ ║║───╠═╝╠═╣╠╦╝ ║ ╚╦╝ ╠═╣║ ║ ║ ║ ║║║║╠═╣ ║ ║║ ║║║║ +# ╩ ╩ ╩╩╩╚══╩╝ ╩ ╩ ╩╩╚═ ╩ ╩ ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩ ╩ ╩ ╩╚═╝╝╚╝ +- industryName: Third-party automation + friendlyName: Borrow off-the-shelf tactics from the community + documentationUrl: https://fleetdm.com/integrations + productCategories: [Endpoint operations,Device management,Vulnerability management] + pricingTableCategories: [Integrations] usualDepartment: IT -# -# ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╦ ╦╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╦═╗╔═╗╔═╗╦╔═╗╔╦╗╦═╗╦ ╦ -# ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║ ║╠═╝ ║║╠═╣ ║ ║╣ ╠╦╝║╣ ║ ╦║╚═╗ ║ ╠╦╝╚╦╝ -# ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╚═╝╩ ═╩╝╩ ╩ ╩ ╚═╝ ╩╚═╚═╝╚═╝╩╚═╝ ╩ ╩╚═ ╩ -- industryName: Private update registry - friendlyName: Update agents from a secret URL - description: Load agent code from a secret URL that you manage. - documentationUrl: https://fleetdm.com/docs/using-fleet/update-agents - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: Security -# -# ╦ ╦╔═╗╦═╗╦╔═╗╔╗ ╦ ╔═╗ ╔═╗╔═╗╔═╗╔╗╔╔╦╗ ╦ ╦╔═╗╦═╗╔═╗╦╔═╗╔╗╔╔═╗ -# ╚╗╔╝╠═╣╠╦╝║╠═╣╠╩╗║ ║╣ ╠═╣║ ╦║╣ ║║║ ║ ╚╗╔╝║╣ ╠╦╝╚═╗║║ ║║║║╚═╗ -# ╚╝ ╩ ╩╩╚═╩╩ ╩╚═╝╩═╝╚═╝ ╩ ╩╚═╝╚═╝╝╚╝ ╩ ╚╝ ╚═╝╩╚═╚═╝╩╚═╝╝╚╝╚═╝ -- industryName: Variable agent versions - descrption: Manage agents remotely by setting different versions per-baseline. - documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration#configure-fleetd-update-channels - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT -# -# ╔═╗╦ ╦╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╔╗ ╦ ╔═╗╔═╗ -# ║ ║ ║╚═╗ ║ ║ ║║║║ ║ ╠═╣╠╩╗║ ║╣ ╚═╗ -# ╚═╝╚═╝╚═╝ ╩ ╚═╝╩ ╩ ╩ ╩ ╩╚═╝╩═╝╚═╝╚═╝ -- industryName: Custom tables - friendlyName: Add tables to osquery with extensions - description: Install osquery extensions over the air. # (GitOptional) - documentationUrl: https://fleetdm.com/docs/configuration/agent-configuration#extensions - moreInfoUrl: https://github.com/trailofbits/osquery-extensions/blob/3df2b72ad78549e25344c79dbc9bce6808c4d92a/README.md#extensions - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT -# -# ╔╗ ╔═╗╔═╗╔═╗╦ ╦╔╗╔╔═╗╔═╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╔═╗╦═╗╔═╗╦ ╦╔═╗╔═╗ -# ╠╩╗╠═╣╚═╗║╣ ║ ║║║║║╣ ╚═╗ ║║║╣ ╚╗╔╝║║ ║╣ ║ ╦╠╦╝║ ║║ ║╠═╝╚═╗ -# ╚═╝╩ ╩╚═╝╚═╝╩═╝╩╝╚╝╚═╝╚═╝ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╚═╝╩╚═╚═╝╚═╝╩ ╚═╝ -- industryName: Baselines (device groups) - friendlyName: Manage different endpoints differently - documentationUrl: https://fleetdm.com/docs/using-fleet/segment-hosts - description: Set baselines and strategies for hosts in different situations called "teams", and move hosts between them via API-driven automations or a simple, delegatable user interface with role-based access. - tier: Premium + description: Plug Fleet into other frameworks and tools like Tines, Snowflake, Terraform, Chronicle, Jira, Zendesk, etc + moreInfoUrl: https://fleetdm.com/integrations + tier: Free jamfProHasFeature: yes jamfProtectHasFeature: yes - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] waysToUse: - - description: Automate remediation for different applications with different security postures (cloud security engineering) -# -# ╔═╗╔═╗╔╗╔╔═╗╦═╗╔═╗╔╦╗╔═╗ ╦═╗╔═╗╔═╗╔═╗╦═╗╔╦╗╔═╗ ╔═╗╔═╗╦═╗ ╔═╗╦═╗╔═╗╦ ╦╔═╗╔═╗ -# ║ ╦║╣ ║║║║╣ ╠╦╝╠═╣ ║ ║╣ ╠╦╝║╣ ╠═╝║ ║╠╦╝ ║ ╚═╗ ╠╣ ║ ║╠╦╝ ║ ╦╠╦╝║ ║║ ║╠═╝╚═╗ -# ╚═╝╚═╝╝╚╝╚═╝╩╚═╩ ╩ ╩ ╚═╝ ╩╚═╚═╝╩ ╚═╝╩╚═ ╩ ╚═╝ ╚ ╚═╝╩╚═ ╚═╝╩╚═╚═╝╚═╝╩ ╚═╝ -# ╔═╗╔═╗ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗╔═╗ -# ║ ║╠╣ ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗ -# ╚═╝╚ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝╚═╝ -- industryName: Generate reports for groups of devices - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#get-query-report + - description: (ActiveDirectory) Know who opened your computer and check their device posture before you let them log into anything. + - description: (Ansible) Easily issue MDM commands and standardize data across operating systems. + - description: (AWS) Deploy your own self-managed Fleet in any AWS environment in minutes. + - description: (Azure) Deploy your own self-managed Fleet in the Microsoft Cloud in minutes. + - description: (Chef) Easily issue MDM commands and standardize data across operating systems. + - description: (Elastic) Ingest osquery data and monitor for important changes or events. + - description: (GitHub) Version control using git, enabling collaboration and a GitOps workflow. + - description: (GitLab) Version control using git, enabling collaboration and a GitOps workflow. + - description: (Chronicle) Ingest osquery data and monitor for important changes or events. + - description: (Google Cloud) Deploy your own self-managed Fleet in any GCP environment in minutes. + - description: (Munki) Easily issue MDM commands and standardize data across operating systems. + - description: (Okta) Know who opened your computer and check their device posture before you let them log into anything. + - description: (Snowflake) Ingest osquery data and monitor for important changes or events. + - description: (Splunk) Ingest osquery data and monitor for important changes or events. + - description: (Tines) Build custom workflows that trigger in various situations. + - description: (Webhooks) Configure automations that send webhooks to specific URLs when Fleet detects changes to host, policy, and CVE statuses. + - description: (Zendesk) Automatically create Zendesk tickets in various situations. + - description: (Jira) Automatically create Jira tickets in various situations, including exporting vulnerabilities to Jira and syncing tickets. + buzzwords: [Snowflake,Okta,Tines,Splunk,Elastic,AWS,ActiveDirectory,Ansible,GitHub,GitLab,Chronicle,Google Cloud,Munki,Vanta,Chef,Zendesk,Jira] +# +# ╔╦╗╦ ╦╦╦═╗╔╦╗ ╔═╗╔═╗╦═╗╔╦╗╦ ╦ ╔═╗╦═╗╔═╗╦ ╦╔═╗╔═╗╔╦╗╦═╗╔═╗╔╦╗╦╔═╗╔╗╔ +# ║ ╠═╣║╠╦╝ ║║───╠═╝╠═╣╠╦╝ ║ ╚╦╝ ║ ║╠╦╝║ ╠═╣║╣ ╚═╗ ║ ╠╦╝╠═╣ ║ ║║ ║║║║ +# ╩ ╩ ╩╩╩╚══╩╝ ╩ ╩ ╩╩╚═ ╩ ╩ ╚═╝╩╚═╚═╝╩ ╩╚═╝╚═╝ ╩ ╩╚═╩ ╩ ╩ ╩╚═╝╝╚╝ +- industryName: Third-party orchestration + friendlyName: Borrow off-the-shelf tactics from legendary brands + documentationUrl: https://fleetdm.com/integrations + description: Plug Fleet into other frameworks and tools like Puppet, Vanta, etc. productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] + pricingTableCategories: [Integrations] usualDepartment: IT + moreInfoUrl: https://fleetdm.com/integrations tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╦═╗╔═╗╦ ╔═╗ ╔╗ ╔═╗╔═╗╔═╗╔╦╗ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗ ╔═╗╔═╗╔╗╔╔╦╗╦═╗╔═╗╦ -# ╠╦╝║ ║║ ║╣───╠╩╗╠═╣╚═╗║╣ ║║ ╠═╣║ ║ ║╣ ╚═╗╚═╗ ║ ║ ║║║║ ║ ╠╦╝║ ║║ -# ╩╚═╚═╝╩═╝╚═╝ ╚═╝╩ ╩╚═╝╚═╝═╩╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ ╚═╝╚═╝╝╚╝ ╩ ╩╚═╚═╝╩═╝ -- industryName: Role-based access control - documentationUrl: https://fleetdm.com/docs/using-fleet/manage-access#manage-access - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╔═╗╔═╗╦ ╦╔═╗╦ ╦ ╔═╗╔═╗╔═╗╦═╗╦╔╗╔╔═╗ -# ╠═╝║ ║║ ║║ ╚╦╝ ╚═╗║ ║ ║╠╦╝║║║║║ ╦ -# ╩ ╚═╝╩═╝╩╚═╝ ╩ ╚═╝╚═╝╚═╝╩╚═╩╝╚╝╚═╝ -- industryName: Policy scoring - documentationUrl: - friendlyName: Mark policies as critical - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Endpoint operations] - usualDepartment: IT - tier: Premium - jamfProHasFeature: no - jamfProtectHasFeature: no waysToUse: - - description: Block access to corporate apps if your end users are failing a specific number of critical policies. - moreInfoUrl: https://github.com/fleetdm/fleet/issues/16206 + - description: (Vanta) Trigger a workflow based on a failing policy. + - description: (Puppet) Easily issue MDM commands, standardize data across operating systems, and map macOS+Windows settings to computers with the Puppet module. + - description: (Torq) Build custom workflows that trigger in various situations. + - description: (Custom IdP) Manage access to Fleet single sign-on (SSO) through any IdP (using SAML). + buzzwords: [Vanta,Puppet,Custom IdP] +# +# ╔╦╗╦ ╦╔╗╔╦╔═╦ ╔═╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔╗ ╦╦ ╦╔╦╗╦ ╦ +# ║║║║ ║║║║╠╩╗║ ║ ║ ║║║║╠═╝╠═╣ ║ ║╠╩╗║║ ║ ║ ╚╦╝ +# ╩ ╩╚═╝╝╚╝╩ ╩╩ ╚═╝╚═╝╩ ╩╩ ╩ ╩ ╩ ╩╚═╝╩╩═╝╩ ╩ ╩ +- industryName: Munki compatibility + visibility + tier: Premium + jamfProHasFeature: yes + jamfProtectHasFeature: yes + usualDepartment: IT + productCategories: [Device management] + pricingTableCategories: [Integrations] # # # ███████╗██╗ ██╗██████╗ ██████╗ ██████╗ ██████╗ ████████╗ @@ -1237,261 +1227,353 @@ jamfProHasFeature: no jamfProtectHasFeature: no # -# ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗ -# ██╔══██╗██╔════╝██╔══██╗██║ ██╔═══██╗╚██╗ ██╔╝████╗ ████║██╔════╝████╗ ██║╚══██╔══╝ -# ██║ ██║█████╗ ██████╔╝██║ ██║ ██║ ╚████╔╝ ██╔████╔██║█████╗ ██╔██╗ ██║ ██║ -# ██║ ██║██╔══╝ ██╔═══╝ ██║ ██║ ██║ ╚██╔╝ ██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║ -# ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ██║ ╚═╝ ██║███████╗██║ ╚████║ ██║ -# ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ -# +# # -# ╔═╗╦╔╦╗╔═╗╔═╗╔═╗ -# ║ ╦║ ║ ║ ║╠═╝╚═╗ -# ╚═╝╩ ╩ ╚═╝╩ ╚═╝ -- industryName: GitOps - friendlyName: Manage endpoints in git - documentationUrl: https://github.com/fleetdm/fleet-gitops - description: Fork the best practices repo and use the GitHub Action to hook it up to your Fleet instance in minutes. - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Deployment] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - demos: - description: A top savings and investment company wanted workflows and automation so that one bad actor can't brick their fleet. This way, they have to make a pull request first. - quote: I don't want one bad actor to brick my fleet. I want them to make a pull request first. - moreInfoUrl: https://docs.google.com/document/d/1hAQL6P--Tt3syq1MTRONAxhQA_2Vjt3oOJJt_O4xbiE/edit?disco=AAABAVnYvns&usp_dm=true#heading=h.7en766pueek4 -# -# ╔═╗╔═╗╦ ╔═╗ ╦ ╦╔═╗╔═╗╔╦╗╔═╗╔╦╗ -# ╚═╗║╣ ║ ╠╣───╠═╣║ ║╚═╗ ║ ║╣ ║║ -# ╚═╝╚═╝╩═╝╚ ╩ ╩╚═╝╚═╝ ╩ ╚═╝═╩╝ -- industryName: Self-hosted - friendlyName: Host it yourself - description: Deploy Fleet anywhere and host it yourself, even in air-gapped environments except where technologically impossible. - pricingTableCategories: [Deployment] - documentationUrl: https://fleetdm.com/docs/deploy/introduction - productCategories: [Endpoint operations,Device management,Vulnerability management] - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no - buzzwords: [Self-hosted] -# -# ╔╦╗╔═╗╔═╗╦ ╔═╗╦ ╦╔╦╗╔═╗╔╗╔╔╦╗ ╔╦╗╔═╗╔═╗╦ ╔═╗ ╔╦╗╔═╗╦═╗╦═╗╔═╗╔═╗╔═╗╦═╗╔╦╗ ╦ ╦╔═╗╦ ╔╦╗ -# ║║║╣ ╠═╝║ ║ ║╚╦╝║║║║╣ ║║║ ║ ║ ║ ║║ ║║ ╚═╗ ║ ║╣ ╠╦╝╠╦╝╠═╣╠╣ ║ ║╠╦╝║║║ ╠═╣║╣ ║ ║║║ -# ═╩╝╚═╝╩ ╩═╝╚═╝ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩ ╚═╝╚═╝╩═╝╚═╝ ╩ ╚═╝╩╚═╩╚═╩ ╩╚ ╚═╝╩╚═╩ ╩┘ ╩ ╩╚═╝╩═╝╩ ╩ -- industryName: Deployment tools (Terraform, Helm) - documentationUrl: https://fleetdm.com/docs/deploy/introduction - usualDepartment: IT - tier: Free - jamfProHasFeature: no - jamfProtectHasFeature: no - productCategories: [Endpoint operations] - pricingTableCategories: [Deployment] -# -# ╔╦╗╔═╗╔╗╔╔═╗╔═╗╔═╗╔╦╗ ╔═╗╦ ╔═╗╦ ╦╔╦╗ -# ║║║╠═╣║║║╠═╣║ ╦║╣ ║║ ║ ║ ║ ║║ ║ ║║ -# ╩ ╩╩ ╩╝╚╝╩ ╩╚═╝╚═╝═╩╝ ╚═╝╩═╝╚═╝╚═╝═╩╝ -- industryName: Managed Cloud - description: Have Fleet host it for you (currently only available for customers with 700+ hosts. PS. Wish we could host for you? We're working on it! Please let us know if you know of a good partner. In the meantime, join fleetdm.com/support and we're happy to help you deploy Fleet yourself.) - pricingTableCategories: [Deployment] - productCategories: [Endpoint operations,Device management,Vulnerability management] - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╔╦╗╦ ╦╦ ╔╦╗╦ ╔╦╗╔═╗╔╗╔╔═╗╔╗╔╔═╗╦ ╦ -# ║║║║ ║║ ║ ║───║ ║╣ ║║║╠═╣║║║║ ╚╦╝ -# ╩ ╩╚═╝╩═╝╩ ╩ ╩ ╚═╝╝╚╝╩ ╩╝╚╝╚═╝ ╩ -- industryName: Multi-tenancy - description: For managed service providers to use a single instance of Fleet for multiple customers. - documentationUrl: https://github.com/fleetdm/fleet/issues/9956 - productCategories: [Device management] - pricingTableCategories: [Deployment] - usualDepartment: IT - buzzwords: [OEM,Private label,House brand,Clear label,Multi-tenancy] - tier: Premium - comingSoonOn: 2024-08-26 #customer-deebradel # -# ██╗███╗ ██╗████████╗███████╗ ██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗███████╗ -# ██║████╗ ██║╚══██╔══╝██╔════╝██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║██╔════╝ -# ██║██╔██╗ ██║ ██║ █████╗ ██║ ███╗██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║███████╗ -# ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██║██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║╚════██║ -# ██║██║ ╚████║ ██║ ███████╗╚██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║███████║ -# ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝ -# # -# ╦═╗╔═╗╔═╗╔╦╗ ╔═╗╔═╗╦ -# ╠╦╝║╣ ╚═╗ ║ ╠═╣╠═╝║ -# ╩╚═╚═╝╚═╝ ╩ ╩ ╩╩ ╩ -- industryName: REST API - friendlyName: Automate any feature - description: - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - documentationUrl: https://fleetdm.com/docs/rest-api/rest-api - screenshotSrc: - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - dri: rachaelshaw # -# ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗ -# ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗ -# ╚╩╝╚═╝╚═╝╩ ╩╚═╝╚═╝╩ ╩╚═╝ -- industryName: Webhooks - friendlyName: Automations - documentationUrl: https://fleetdm.com/docs/using-fleet/automations#automations - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes # -# ╔═╗╔═╗╔╦╗╔╦╗╔═╗╔╗╔╔╦╗ ╦ ╦╔╗╔╔═╗ ╔╦╗╔═╗╔═╗╦ ┌─ ╔═╗╦ ╦ ─┐ -# ║ ║ ║║║║║║║╠═╣║║║ ║║ ║ ║║║║║╣ ║ ║ ║║ ║║ │ ║ ║ ║ │ -# ╚═╝╚═╝╩ ╩╩ ╩╩ ╩╝╚╝═╩╝ ╩═╝╩╝╚╝╚═╝ ╩ ╚═╝╚═╝╩═╝ └─ ╚═╝╩═╝╩ ─┘ -- industryName: Command line tool (CLI) - friendlyName: fleetctl - documentationUrl: https://fleetdm.com/docs/using-fleet/fleetctl-cli - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Integrations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╔═╗╦═╗╔═╗╔╗╔╔╦╗ ╔═╗╔═╗╦ ╔═╗╔╗╔╦ ╦ ╦ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗ -# ║ ╦╠╦╝╠═╣║║║ ║ ╠═╣╠═╝║───║ ║║║║║ ╚╦╝ ╠═╣║ ║ ║╣ ╚═╗╚═╗ -# ╚═╝╩╚═╩ ╩╝╚╝ ╩ ╩ ╩╩ ╩ ╚═╝╝╚╝╩═╝╩ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ -- industryName: Grant API-only access - documentationUrl: https://fleetdm.com/docs/using-fleet/fleetctl-cli#using-fleetctl-with-an-api-only-user - productCategories: [Endpoint operations] - pricingTableCategories: [Integrations] - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╔═╗╦╔╗╔╔═╗╦ ╔═╗ ╔═╗╦╔═╗╔╗╔ ╔═╗╔╗╔ -# ╚═╗║║║║║ ╦║ ║╣ ╚═╗║║ ╦║║║ ║ ║║║║ -# ╚═╝╩╝╚╝╚═╝╩═╝╚═╝ ╚═╝╩╚═╝╝╚╝ ╚═╝╝╚╝ -- industryName: Single sign on (SSO, SAML) - documentationUrl: https://fleetdm.com/docs/deploy/single-sign-on-sso#single-sign-on-sso - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes -# -# ╦╦ ╦╔═╗╔╦╗ ╦╔╗╔ ╔╦╗╦╔╦╗╔═╗ ╔═╗╦═╗╔═╗╦ ╦╦╔═╗╦╔═╗╔╗╔╦╔╗╔╔═╗ -# ║║ ║╚═╗ ║───║║║║───║ ║║║║║╣ ╠═╝╠╦╝║ ║╚╗╔╝║╚═╗║║ ║║║║║║║║║ ╦ -# ╚╝╚═╝╚═╝ ╩ ╩╝╚╝ ╩ ╩╩ ╩╚═╝ ╩ ╩╚═╚═╝ ╚╝ ╩╚═╝╩╚═╝╝╚╝╩╝╚╝╚═╝ -- industryName: Just-in-time (JIT) provisioning - documentationUrl: https://fleetdm.com/docs/deploy/single-sign-on-sso#just-in-time-jit-user-provisioning - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: no # -# ╔╦╗╔═╗╔═╗╔═╗ ╔═╗╦ ╦╔╦╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ -# ║║║╣ ║╣ ╠═╝ ╠═╣║ ║ ║ ║ ║║║║╠═╣ ║ ║║ ║║║║╚═╗ -# ═╩╝╚═╝╚═╝╩ ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ -- industryName: Deep automations - friendlyName: Trigger webhooks or run scripts - documentationUrl: https://fleetdm.com/docs/using-fleet/automations#automations - description: Fire off webhooks or run scripts on hosts when certain things happen in Fleet. - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - comingSoonOn: 2024-12-31 - tier: Free - buzzwords: [Automated remediation,Auto-remediation,Self-healing] - waysToUse: - - description: Use policy automations to automatically remediate issues and mitigate vulnerabilities. - - description: Use osquery and santa to work around inflexibilities in proprietary MDMs and other protection solutions. - - description: Listen to webhooks to perform autonomous self-healing (cloud security engineering) - moreInfoUrl: https://www.fugue.co/blog/automated-remediation-scripts-vs.-self-healing-infrastructure-two-approaches-to-cloud-security +# █████╗ ██████╗ ██████╗██╗ ██╗██╗██╗ ██╗███████╗ +# ██╔══██╗██╔══██╗██╔════╝██║ ██║██║██║ ██║██╔════╝ +# ███████║██████╔╝██║ ███████║██║██║ ██║█████╗ +# ██╔══██║██╔══██╗██║ ██╔══██║██║╚██╗ ██╔╝██╔══╝ +# ██║ ██║██║ ██║╚██████╗██║ ██║██║ ╚████╔╝ ███████╗ +# ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ # -# ╦╔╗╔╔╦╗╔═╗╔═╗╦═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ -# ║║║║ ║ ║╣ ║ ╦╠╦╝╠═╣ ║ ║║ ║║║║╚═╗ -# ╩╝╚╝ ╩ ╚═╝╚═╝╩╚═╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ -- industryName: Integrations (Tines, Snowflake, Terraform, Chronicle, Jira, Zendesk, etc) - friendlyName: Borrow off-the-shelf tactics from the community - documentationUrl: https://fleetdm.com/integrations - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - description: - moreInfoUrl: https://fleetdm.com/integrations - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: yes - waysToUse: - - description: (ActiveDirectory) Know who opened your computer and check their device posture before you let them log into anything. - - description: (Ansible) Easily issue MDM commands and standardize data across operating systems. - - description: (AWS) Deploy your own self-managed Fleet in any AWS environment in minutes. - - description: (Azure) Deploy your own self-managed Fleet in the Microsoft Cloud in minutes. - - description: (Chef) Easily issue MDM commands and standardize data across operating systems. - - description: (Elastic) Ingest osquery data and monitor for important changes or events. - - description: (GitHub) Version control using git, enabling collaboration and a GitOps workflow. - - description: (GitLab) Version control using git, enabling collaboration and a GitOps workflow. - - description: (Chronicle) Ingest osquery data and monitor for important changes or events. - - description: (Google Cloud) Deploy your own self-managed Fleet in any GCP environment in minutes. - - description: (Munki) Easily issue MDM commands and standardize data across operating systems. - - description: (Okta) Know who opened your computer and check their device posture before you let them log into anything. - - description: (Snowflake) Ingest osquery data and monitor for important changes or events. - - description: (Splunk) Ingest osquery data and monitor for important changes or events. - - description: (Tines) Build custom workflows that trigger in various situations. - - description: (Webhooks) Configure automations that send webhooks to specific URLs when Fleet detects changes to host, policy, and CVE statuses. - - description: (Zendesk) Automatically create Zendesk tickets in various situations. - - description: (Jira) Automatically create Jira tickets in various situations, including exporting vulnerabilities to Jira and syncing tickets. - buzzwords: [Snowflake,Okta,Tines,Splunk,Elastic,AWS,ActiveDirectory,Ansible,GitHub,GitLab,Chronicle,Google Cloud,Munki,Vanta,Chef,Zendesk,Jira] # -# ╔═╗╦═╗╔═╗╔╦╗╦╦ ╦╔╦╗ ╦╔╗╔╔╦╗╔═╗╔═╗╦═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ -# ╠═╝╠╦╝║╣ ║║║║║ ║║║║ ║║║║ ║ ║╣ ║ ╦╠╦╝╠═╣ ║ ║║ ║║║║╚═╗ -# ╩ ╩╚═╚═╝╩ ╩╩╚═╝╩ ╩ ╩╝╚╝ ╩ ╚═╝╚═╝╩╚═╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ -- industryName: Premium integrations (Puppet, Vanta, etc) - friendlyName: Borrow off-the-shelf tactics from legendary brands - documentationUrl: https://fleetdm.com/integrations - description: Plug Fleet into other frameworks and tools. - productCategories: [Endpoint operations,Device management,Vulnerability management] - pricingTableCategories: [Integrations] - usualDepartment: IT - moreInfoUrl: https://fleetdm.com/integrations - tier: Premium - waysToUse: - - description: (Vanta) Trigger a workflow based on a failing policy. - - description: (Puppet) Easily issue MDM commands, standardize data across operating systems, and map macOS+Windows settings to computers with the Puppet module. - - description: (Torq) Build custom workflows that trigger in various situations. - - description: (Custom IdP) Manage access to Fleet single sign-on (SSO) through any IdP (using SAML). - buzzwords: [Vanta,Puppet,Custom IdP] -# -# ╔╦╗╦ ╦╔╗╔╦╔═╦ ╔═╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔╗ ╦╦ ╦╔╦╗╦ ╦ -# ║║║║ ║║║║╠╩╗║ ║ ║ ║║║║╠═╝╠═╣ ║ ║╠╩╗║║ ║ ║ ╚╦╝ -# ╩ ╩╚═╝╝╚╝╩ ╩╩ ╚═╝╚═╝╩ ╩╩ ╩ ╩ ╩ ╩╚═╝╩╩═╝╩ ╩ ╩ -- industryName: Munki compatibility + visibility - tier: Premium - jamfProHasFeature: yes - jamfProtectHasFeature: yes - usualDepartment: IT - productCategories: [Device management] - pricingTableCategories: [Integrations] +# # ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ ╔═╗╔╗╔╔═╗╦╔╗╔╔═╗╔═╗╦═╗╦╔╗╔╔═╗ +# # ║║║╣ ║ ║╣ ║ ║ ║║ ║║║║ ║╣ ║║║║ ╦║║║║║╣ ║╣ ╠╦╝║║║║║ ╦ +# # ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ ╚═╝╝╚╝╚═╝╩╝╚╝╚═╝╚═╝╩╚═╩╝╚╝╚═╝ +# - industryName: Detection engineering +# friendlyName: # Ship logs to your data lake and comopare with known bad binary hashes or capture behavioral data and build custom detections (e.g. using a framework like MITRE) +# description: +# documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: yes +# dri: mikermcneil +# usualDepartment: Security +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# buzzwords: [Security analytics,Behavioral analytics,MITRE ATT&CK,Tactics techniques and procedures (TTPs),Security information and event management (SIEM)] +# demos: +# - description: +# moreInfoUrl: +# waysToUse: +# - description: # +# +# # ╦╔╗╔╔╦╗╦═╗╦ ╦╔═╗╔╦╗╦╔═╗╔╗╔ ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔ +# # ║║║║ ║ ╠╦╝║ ║╚═╗ ║ ║║ ║║║║ ║║║╣ ║ ║╣ ║ ║ ║║ ║║║║ +# # ╩╝╚╝ ╩ ╩╚═╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩╚═╝╝╚╝ +# - industryName: Intrusion detection +# friendlyName: Build custom query and policy automations to detect suspicious behavior +# description: Send webhooks and ship logs to detect intrusions and issues with devices. +# documentationUrl: https://fleetdm.com/docs/using-fleet/log-destinations +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: yes +# usualDepartment: Security +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# buzzwords: [Host-based intrusion detection system (HIDS,Indicators of Compromise (IOCs),Feeder for SIEM] +# demos: +# - description: A top media company wanted to share more security data with other departments without slowing down hosts. +# waysToUse: +# - description: Send webhooks to generate alerts when an IOC is detected on one or more devices. +# - description: Ship logs to Splunk, Snowflake, and other SIEMs to build a host-based intrusion detection system (HIDS). +# - description: Synchronize live state of endpoints to a data lake or SIEM in a consistent shape. +# - description: Export the data to other systems +# moreInfoUrl: https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit +# - description: Export data to a third-party SIEM tool +# moreInfoUrl: https://www.websense.com/content/support/library/web/hosted/admin_guide/siem_integration_explain.aspx +# - description: Gather data and log events from endpoints +# moreInfoUrl: https://techbeacon.com/security/how-osquery-can-lift-your-security-teams-game#:~:text=%22If%20security%20teams%20didn%27t%20have%20osquery%2C%20they%20would%20have%20to%20find%20a%20way%20to%20manually%20go%20into%20each%20endpoint%20and%20gather%20data%2C%20or%20buy%20a%20third%2Dparty%20tool%20to%20do%20that%20for%20them +# +# # ╔╦╗╦ ╦╦═╗╔═╗╔═╗╔╦╗ ╦ ╦╦ ╦╔╗╔╔╦╗╦╔╗╔╔═╗ +# # ║ ╠═╣╠╦╝║╣ ╠═╣ ║ ╠═╣║ ║║║║ ║ ║║║║║ ╦ +# # ╩ ╩ ╩╩╚═╚═╝╩ ╩ ╩ ╩ ╩╚═╝╝╚╝ ╩ ╩╝╚╝╚═╝ +# - industryName: Threat hunting +# friendlyName: # TODO: live query +# description: +# documentationUrl: https://fleetdm.com/queries +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: yes +# dri: mikermcneil +# usualDepartment: Security +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# buzzwords: [] +# demos: +# - description: +# moreInfoUrl: +# waysToUse: +# - description: +# +# +# # ╔╦╗╔═╗╔╦╗╔═╗╔═╗╔╦╗ ╔═╗╔╗╔╔╦╗ ╔═╗╦ ╦╦═╗╔═╗╔═╗╔═╗╔═╗ ╦╔═╗╔═╗╦ ╦╔═╗╔═╗ ╦ ╦╦╔╦╗╦ ╦ +# # ║║║╣ ║ ║╣ ║ ║ ╠═╣║║║ ║║ ╚═╗║ ║╠╦╝╠╣ ╠═╣║ ║╣ ║╚═╗╚═╗║ ║║╣ ╚═╗ ║║║║ ║ ╠═╣ +# # ═╩╝╚═╝ ╩ ╚═╝╚═╝ ╩ ╩ ╩╝╚╝═╩╝ ╚═╝╚═╝╩╚═╚ ╩ ╩╚═╝╚═╝ ╩╚═╝╚═╝╚═╝╚═╝╚═╝ ╚╩╝╩ ╩ ╩ ╩ +# # ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗╔═╗ +# # ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗ +# # ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝╚═╝ +# - industryName: Detect and surface issues with devices (policies) +# documentationUrl: https://fleetdm.com/docs/get-started/anatomy#policy +# productCategories: [Endpoint operations,Device management] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: yes +# +# +# # ╔╗ ╔═╗╔╦╗╔═╗╦ ╦ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗╦╔═╗╔╗╔ +# # ╠╩╗╠═╣ ║ ║ ╠═╣ ║║║║╚═╗ ║ ╠═╣║ ║ ╠═╣ ║ ║║ ║║║║ +# # ╚═╝╩ ╩ ╩ ╚═╝╩ ╩ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╩ ╩ ╩ ╩╚═╝╝╚╝ +# - industryName: Batch installation (Chef, Ansible, Puppet, MDM) +# friendlyName: Install agents over the air +# documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# +# +# # ╦ ╦╦ ╦╦ ╔╗╔╔═╗╦═╗╔═╗╔╗ ╦╦ ╦╔╦╗╦ ╦ ╔╦╗╔═╗╔═╗╦ ╦╔╗ ╔═╗╔═╗╦═╗╔╦╗ +# # ╚╗╔╝║ ║║ ║║║║╣ ╠╦╝╠═╣╠╩╗║║ ║ ║ ╚╦╝ ║║╠═╣╚═╗╠═╣╠╩╗║ ║╠═╣╠╦╝ ║║ +# # ╚╝ ╚═╝╩═╝╝╚╝╚═╝╩╚═╩ ╩╚═╝╩╩═╝╩ ╩ ╩ ═╩╝╩ ╩╚═╝╩ ╩╚═╝╚═╝╩ ╩╩╚══╩╝ +# - industryName: Vulnerability dashboard +# friendlyName: Vulnerability dashboard +# documentationUrl: https://fleetdm.com/vulnerability-management +# productCategories: [Vulnerability management] +# pricingTableCategories: [Devices] +# usualDepartment: Security +# tier: Premium +# jamfProHasFeature: no +# jamfProtectHasFeature: yes +# demos: +# - description: See a list of all vulnerabilities across your hosts. +# moreInfoUrl: https://github.com/fleetdm/fleet/issues/15919 +# - description: AI generated CVSS v4 context. Coming soon (2024-12-31). +# waysToUse: +# - description: Easily communicate to executives regarding the progress of patching vulnerable software. Only show vulnerabilities that you care about. +# +# +# # ╔═╗╦ ╔═╗╔═╗╔╗╔╔═╗╦═╗╔═╗╔╦╗╔═╗╔╦╗ ╔╦╗╔═╗╔═╗╔═╗╦═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ +# # ╠═╣║───║ ╦║╣ ║║║║╣ ╠╦╝╠═╣ ║ ║╣ ║║ ║║║╣ ╚═╗║ ╠╦╝║╠═╝ ║ ║║ ║║║║╚═╗ +# # ╩ ╩╩ ╚═╝╚═╝╝╚╝╚═╝╩╚═╩ ╩ ╩ ╚═╝═╩╝ ═╩╝╚═╝╚═╝╚═╝╩╚═╩╩ ╩ ╩╚═╝╝╚╝╚═╝ +# - industryName: AI-generated descriptions (optional) +# description: Optionally use AI to explain why your security policies matter. +# documentationUrl: https://github.com/fleetdm/fleet/issues/18187 +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# +# +# CONSOLIDATED WITH DEVICE HEALTH +# # ╔═╗╦ ╦╔╦╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗ ╔═╗╔═╗╔═╗╔╦╗╦ ╦╦═╗╔═╗ ╔═╗╔═╗╔═╗╔═╗╔═╗╔═╗╔╦╗╔═╗╔╗╔╔╦╗ +# # ╠═╣║ ║ ║ ║ ║║║║╠═╣ ║ ║║ ╠═╝║ ║╚═╗ ║ ║ ║╠╦╝║╣ ╠═╣╚═╗╚═╗║╣ ╚═╗╚═╗║║║║╣ ║║║ ║ +# # ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩ ╩ ╩ ╩╚═╝ ╩ ╚═╝╚═╝ ╩ ╚═╝╩╚═╚═╝ ╩ ╩╚═╝╚═╝╚═╝╚═╝╚═╝╩ ╩╚═╝╝╚╝ ╩ +# - industryName: Automatic posture assessment +# friendlyName: Verify any security or compliance goal +# description: Simplify security audits, build definitive reports, and discover + verify ongoing compliance for every endpoint, from workstations to data centers. +# documentationUrl: https://fleetdm.com/docs/using-fleet/cis-benchmarks#cis-benchmarks +# screenshotSrc: +# usualDepartment: Security +# tier: Free +# jamfProHasFeature: no +# jamfProtectHasFeature: yes +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# dri: mikermcneil +# demos: +# - description: A large tech company used Fleet's CIS Benchmark policies to automatically assess posuture of 80,000 endpoints. +# quote: +# moreInfoUrl: +# buzzwords: [Attack surface management (ASM),Endpoint hardening,Security posture,Cyber hygiene,Anomaly detection,Configuration management,Attack Surface Monitoring,Policy assessment] +# waysToUse: +# # Consolidated ways to use with "Device health" +# +# +# CONSOLIDATED WITH DEVICE HEALTH +# # ╔═╗╔═╗╦ ╦╔═╗╦ ╦ ╔═╗╔═╗╔═╗╦═╗╦╔╗╔╔═╗ +# # ╠═╝║ ║║ ║║ ╚╦╝ ╚═╗║ ║ ║╠╦╝║║║║║ ╦ +# # ╩ ╚═╝╩═╝╩╚═╝ ╩ ╚═╝╚═╝╚═╝╩╚═╩╝╚╝╚═╝ +# - industryName: Policy scoring +# documentationUrl: +# friendlyName: Mark policies as critical +# productCategories: [Endpoint operations,Device management] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# tier: Premium +# jamfProHasFeature: no +# jamfProtectHasFeature: no +# waysToUse: +# # Consolidated ways to use with "Device health" +# +# +# CONSOLIDATED WITH TEAMS +# # ╦ ╦╔═╗╦═╗╦╔═╗╔╗ ╦ ╔═╗ ╔═╗╔╗╔╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╔╗╔╔╦╗ +# # ╚╗╔╝╠═╣╠╦╝║╠═╣╠╩╗║ ║╣ ║╣ ║║║╠╦╝║ ║║ ║ ║║║║╣ ║║║ ║ +# # ╚╝ ╩ ╩╩╚═╩╩ ╩╚═╝╩═╝╚═╝ ╚═╝╝╚╝╩╚═╚═╝╩═╝╩═╝╩ ╩╚═╝╝╚╝ ╩ +# - industryName: Variable enrollment +# description: Enroll hosts in different groups using different enrollment secrets and/or installers per-baseline. +# documentationUrl: https://fleetdm.com/docs/using-fleet/segment-hosts +# tier: Premium +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations, Device management] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# +# +# # ╔═╗╔═╗╔═╗╔╗╔╔╦╗ ╔═╗╦ ╦╔╦╗╔═╗ ╦ ╦╔═╗╔╦╗╔═╗╔╦╗╔═╗ +# # ╠═╣║ ╦║╣ ║║║ ║ ╠═╣║ ║ ║ ║ ║───║ ║╠═╝ ║║╠═╣ ║ ║╣ +# # ╩ ╩╚═╝╚═╝╝╚╝ ╩ ╩ ╩╚═╝ ╩ ╚═╝ ╚═╝╩ ═╩╝╩ ╩ ╩ ╚═╝ +# - industryName: Agent auto-update (optional) +# friendlyName: Keep agents and extensions up to date +# description: Optionally keep agents and extensions up to date automatically using Fleet's free update registry, powered by The Update Framework (TUF). +# documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# +# +# # ╔╦╗╔═╗╔═╗╔═╗ ╔═╗╦ ╦╔╦╗╔═╗╔╦╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ +# # ║║║╣ ║╣ ╠═╝ ╠═╣║ ║ ║ ║ ║║║║╠═╣ ║ ║║ ║║║║╚═╗ +# # ═╩╝╚═╝╚═╝╩ ╩ ╩╚═╝ ╩ ╚═╝╩ ╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ +# - industryName: Deep automations +# friendlyName: Trigger webhooks or run scripts +# documentationUrl: https://fleetdm.com/docs/using-fleet/automations#automations +# description: Fire off webhooks or run scripts on hosts when certain things happen in Fleet. +# productCategories: [Endpoint operations,Device management,Vulnerability management] +# pricingTableCategories: [Integrations] +# comingSoonOn: 2024-12-31 +# tier: Free +# buzzwords: [Automated remediation,Auto-remediation,Self-healing] +# waysToUse: +# - description: Use policy automations to automatically remediate issues and mitigate vulnerabilities. +# - description: Use osquery and santa to work around inflexibilities in proprietary MDMs and other protection solutions. +# - description: Listen to webhooks to perform autonomous self-healing (cloud security engineering) +# moreInfoUrl: https://www.fugue.co/blog/automated-remediation-scripts-vs.-self-healing-infrastructure-two-approaches-to-cloud-security +# +# +# CONSOLIDATED WITH "SELF-SERVICE APPLICATION INSTALLATION" +# # ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╦═╗╔═╗ +# # ║║║║╚═╗ ║ ╠═╣║ ║ ║╣ ╠╦╝╚═╗ +# # ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╚═╝╩╚═╚═╝ +# - industryName: Installers (self-service) +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# documentationUrl: https://fleetdm.com/docs/using-fleet/enroll-hosts +# waysToUse: +# CONSOLIDATED WITH "SELF-SERVICE APPLICATION INSTALLATION" +# +# +# CONSOLIDATED WITH "POLICIES" # ╔╦╗╦═╗╦╔═╗╔═╗╔═╗╦═╗ ╔═╗ ╦ ╦╔═╗╦═╗╦╔═╔═╗╦ ╔═╗╦ ╦ ╔╗ ╔═╗╔═╗╔═╗╔╦╗ ╔═╗╔╗╔ ╔═╗ # ║ ╠╦╝║║ ╦║ ╦║╣ ╠╦╝ ╠═╣ ║║║║ ║╠╦╝╠╩╗╠╣ ║ ║ ║║║║ ╠╩╗╠═╣╚═╗║╣ ║║ ║ ║║║║ ╠═╣ # ╩ ╩╚═╩╚═╝╚═╝╚═╝╩╚═ ╩ ╩ ╚╩╝╚═╝╩╚═╩ ╩╚ ╩═╝╚═╝╚╩╝ ╚═╝╩ ╩╚═╝╚═╝═╩╝ ╚═╝╝╚╝ ╩ ╩ # ╔═╗╔═╗╦╦ ╦╔╗╔╔═╗ ╔═╗╔═╗╦ ╦╔═╗╦ ╦ # ╠╣ ╠═╣║║ ║║║║║ ╦ ╠═╝║ ║║ ║║ ╚╦╝ # ╚ ╩ ╩╩╩═╝╩╝╚╝╚═╝ ╩ ╚═╝╩═╝╩╚═╝ ╩ -- industryName: Trigger a workflow based on a failing policy - documentationUrl: https://fleetdm.com/docs/using-fleet/automations#policy-automations - productCategories: [Endpoint operations,Device management] - pricingTableCategories: [Integrations] - usualDepartment: IT - tier: Free - jamfProHasFeature: yes - jamfProtectHasFeature: no +# - industryName: Trigger a workflow based on a failing policy +# documentationUrl: https://fleetdm.com/docs/using-fleet/automations#policy-automations +# productCategories: [Endpoint operations,Device management] +# pricingTableCategories: [Integrations] +# usualDepartment: IT +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# +# +# CONSOLIDATED WITH "DEVICE INVENTORY" +# # ╦ ╦╔═╗╦═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ +# # ╠═╣╠═╣╠╦╝ ║║║║║╠═╣╠╦╝║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ +# # ╩ ╩╩ ╩╩╚══╩╝╚╩╝╩ ╩╩╚═╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ +# - industryName: Hardware inventory +# documentationUrl: https://fleetdm.com/tables/system_info +# productCategories: [Endpoint operations,Device management,Vulnerability management] +# pricingTableCategories: [Devices] +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# waysToUse: +# - description: Implement hardware and infrastructure inventory recommendations from the SANS 20 / CIS 18. +# moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 +# + +# CONSOLIDATED WITH "DEVICE INVENTORY" +# # ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ ╔╦╗╔═╗╔═╗╦ ╦╔╗ ╔═╗╔═╗╦═╗╔╦╗ +# # ║║║╣ ╚╗╔╝║║ ║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ ║║╠═╣╚═╗╠═╣╠╩╗║ ║╠═╣╠╦╝ ║║ +# # ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ ═╩╝╩ ╩╚═╝╩ ╩╚═╝╚═╝╩ ╩╩╚══╩╝ +# - industryName: Device inventory dashboard +# documentationUrl: +# productCategories: [Endpoint operations,Device management] +# pricingTableCategories: [Devices] +# usualDepartment: IT +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: yes +# +# +# CONSOLIDATED WITH "DEVICE INVENTORY" +# # ╔═╗╔═╗╔═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╦╔╗╔╦ ╦╔═╗╔╗╔╔╦╗╔═╗╦═╗╦ ╦ +# # ╚═╗║ ║╠╣ ║ ║║║╠═╣╠╦╝║╣ ║║║║╚╗╔╝║╣ ║║║ ║ ║ ║╠╦╝╚╦╝ +# # ╚═╝╚═╝╚ ╩ ╚╩╝╩ ╩╩╚═╚═╝ ╩╝╚╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝╩╚═ ╩ +# - industryName: Software inventory +# documentationUrl: https://fleetdm.com/docs/get-started/anatomy#software-library +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# productCategories: [Endpoint operations,Device management,Vulnerability management] +# pricingTableCategories: [Devices] +# waysToUse: +# - description: Implement software inventory recommendations from the SANS 20 / CIS 18. +# moreInfoUrl: https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4 +# - description: View a list of all software and their versions installed on all your hosts. +# - description: View a list of software rolled up by title. +# moreInfoUrl: https://github.com/fleetdm/fleet/issues/14674 +# # +# +# CONSOLIDATED WITH "DEVICE INVENTORY" +# # ╔╗ ╦═╗╔═╗╦ ╦╔═╗╔═╗ ╦╔╗╔╔═╗╔╦╗╔═╗╦ ╦ ╔═╗╔╦╗ ╔═╗╔═╗╔═╗╔╦╗╦ ╦╔═╗╦═╗╔═╗ ╔═╗╔═╗╔═╗╦╔═╔═╗╔═╗╔═╗╔═╗ +# # ╠╩╗╠╦╝║ ║║║║╚═╗║╣ ║║║║╚═╗ ║ ╠═╣║ ║ ║╣ ║║ ╚═╗║ ║╠╣ ║ ║║║╠═╣╠╦╝║╣ ╠═╝╠═╣║ ╠╩╗╠═╣║ ╦║╣ ╚═╗ +# # ╚═╝╩╚═╚═╝╚╩╝╚═╝╚═╝ ╩╝╚╝╚═╝ ╩ ╩ ╩╩═╝╩═╝╚═╝═╩╝ ╚═╝╚═╝╚ ╩ ╚╩╝╩ ╩╩╚═╚═╝ ╩ ╩ ╩╚═╝╩ ╩╩ ╩╚═╝╚═╝╚═╝ +# - industryName: Browse installed software packages +# documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#software +# productCategories: [Endpoint operations,Device management,Vulnerability management] +# pricingTableCategories: [Devices] +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: no +# +# +# CONSOLIDATED WITH "SEARCH INVENTORY" +# # ╔═╗╔═╗╔═╗╦═╗╔═╗╦ ╦ ╔╦╗╔═╗╦ ╦╦╔═╗╔═╗╔═╗ ╔╗ ╦ ╦ ╦╔═╗ ╔═╗╔═╗╦═╗╦╔═╗╦ +# # ╚═╗║╣ ╠═╣╠╦╝║ ╠═╣ ║║║╣ ╚╗╔╝║║ ║╣ ╚═╗ ╠╩╗╚╦╝ ║╠═╝ ╚═╗║╣ ╠╦╝║╠═╣║ +# # ╚═╝╚═╝╩ ╩╩╚═╚═╝╩ ╩ ═╩╝╚═╝ ╚╝ ╩╚═╝╚═╝╚═╝ ╚═╝ ╩ ╩╩┘ ╚═╝╚═╝╩╚═╩╩ ╩╩═╝┘ +# # ╦ ╦╔═╗╔═╗╔╦╗╔╗╔╔═╗╔╦╗╔═╗ ╦ ╦╦ ╦╦╔╦╗ +# # ╠═╣║ ║╚═╗ ║ ║║║╠═╣║║║║╣ ║ ║║ ║║ ║║ +# # ╩ ╩╚═╝╚═╝ ╩ ╝╚╝╩ ╩╩ ╩╚═╝┘ ╚═╝╚═╝╩═╩╝ +# - industryName: Search devices by IP, serial, hostname, UUID +# documentationUrl: https://fleetdm.com/docs/rest-api/rest-api#hosts +# productCategories: [Endpoint operations,Device management] +# pricingTableCategories: [Devices] +# tier: Free +# jamfProHasFeature: yes +# jamfProtectHasFeature: yes +# diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index 17b3acbd15..1308b2ea58 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -1,4 +1,5 @@ # 🛩️ Product groups + This page covers what all contributors (fleeties or not) need to know in order to contribute changes to [the core product](https://fleetdm.com/docs). When creating software, handoffs between teams or contributors are one of the most common sources of miscommunication and waste. Like [GitLab](https://docs.google.com/document/d/1RxqS2nR5K0vN6DbgaBw7SEgpPLi0Kr9jXNGzpORT-OY/edit#heading=h.7sfw1n9c1i2t), Fleet uses product groups to minimize handoffs and maximize iteration and efficiency in the way we build the product. @@ -6,10 +7,14 @@ 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). + +Fleet team members can read [Fleet's high-level product goals for the current quarter](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) (confidential Google Sheet). + ## What are product groups? + Fleet organizes product development efforts into separate, cross-functional product groups that include product designers, developers, and quality engineers. These product groups are organized by business goal, and designed to operate in parallel. Security, performance, stability, scalability, database migrations, release compatibility, usage documentation (such as REST API and configuration reference), contributor experience, and support escalation are the responsibility of every product group. @@ -18,33 +23,34 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) > Ideas expressed in wireframes, like code contributions, [are welcome from everyone](https://chat.osquery.io/c/fleet), inside or outside the company. + ## Current product groups -| Product group | Goal _(value for customers and/or community)_ | Capacity\* | -|:--------------------------|:--------------------------------------------------------------------|:-----------------| -| [Endpoint ops](#endpoint-ops-group) | Increase and exceed maturity in the "Endpoint operations" category. | 130 | -| [MDM](#mdm-group) | Reach maturity in the "MDM" product category. | 156 | +| Product group | Goal _(value for customers and/or community)_ | Capacity\* | +|:-----------------------------------------|:----------------------------------------------------------------------|:-------------| +| [Endpoint ops](#endpoint-ops-group) | Increase and exceed maturity in the "Endpoint operations" category. | 156 | +| [MDM](#mdm-group) | Reach maturity in the "MDM" product category. | 156 | -\* 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. +\* The number of [estimated story points](https://fleetdm.com/handbook/company/communications#estimation-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)._ -> -> _Why? Making users and customers happier and more successful is the goal of _every_ product group. This includes simpler usage, lovable design + help text + error messages, fixed bugs, responding quickly to incidents, using Fleet's brand standards, more successful customer onboarding, features that drive more win-win meetings with contributors and Fleet's sales team, and "whole product solutions", including professional services, design partnerships, and training._ ### Endpoint ops group + The goal of the endpoint ops group is to increase and exceed [Fleet's product maturity goals in the endpoint operations category](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing). | Responsibility | Human(s) | |:----------------------------------|:--------------------------| -| Product Designer | [Rachael Shaw](https://www.linkedin.com/in/rachaelcshaw/) _([@rachaelshaw](https://github.com/rachaelshaw))_ +| Product Designer | [Rachael Shaw](https://www.linkedin.com/in/rachaelcshaw/) _([@rachaelshaw](https://github.com/rachaelshaw))_, [Randy Hill](https://www.linkedin.com/in/rockingmoose/) _([@randy-fleet](https://github.com/randy-fleet))_ | Engineering Manager | [Sharon Katz](https://www.linkedin.com/in/sharon-katz-45b1b3a/) _([@sharon-fdm](https://github.com/sharon-fdm))_ | Product Manager | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/@noahtalerman))_ | Quality Assurance | [Reed Haynes](https://www.linkedin.com/in/reed-haynes-633a69a3/) _([@xpkoala](https://github.com/xpkoala))_ -| Developer | [Jacob Shandling](https://www.linkedin.com/in/jacob-shandling/) _([@jacobshandling](https://github.com/jacobshandling))_, [Lucas Rodriguez](https://www.linkedin.com/in/lukmr/) _([@lucasmrod](https://github.com/lucasmrod))_, [Rachel Perkins](https://www.linkedin.com/in/rachelelysia/) _([@rachelelysia](https://github.com/rachelelysia))_, [Eric Shaw](https://www.linkedin.com/in/eric-shaw-1423831a9/) _([@eashaw](https://github.com/eashaw))_, [Tim Lee](https://www.linkedin.com/in/mostlikelee/) _([@mostlikelee](https://github.com/mostlikelee))_, [Victor Lyuboslavsky](https://www.linkedin.com/in/lyuboslavsky/) _([@getvictor](https://github.com/getvictor))_ +| Developer | [Jacob Shandling](https://www.linkedin.com/in/jacob-shandling/) _([@jacobshandling](https://github.com/jacobshandling))_, [Lucas Rodriguez](https://www.linkedin.com/in/lukmr/) _([@lucasmrod](https://github.com/lucasmrod))_, [Rachel Perkins](https://www.linkedin.com/in/rachelelysia/) _([@rachelelysia](https://github.com/rachelelysia))_, [Eric Shaw](https://www.linkedin.com/in/eric-shaw-1423831a9/) _([@eashaw](https://github.com/eashaw))_, [Tim Lee](https://www.linkedin.com/in/mostlikelee/) _([@mostlikelee](https://github.com/mostlikelee))_, [Victor Lyuboslavsky](https://www.linkedin.com/in/lyuboslavsky/) _([@getvictor](https://github.com/getvictor))_, [Ian Littman](https://www.linkedin.com/in/ian-littman/) _([@iansltx](https://github.com/iansltx))_ > The [Slack channel](https://fleetdm.slack.com/archives/C01EZVBHFHU), [kanban release board](https://app.zenhub.com/workspaces/-g-endpoint-ops-current-sprint-63bd7e0bf75dba002a2343ac/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-endpoint-ops) for this product group is `#g-endpoint-ops`. + ### MDM group + The goal of the MDM group is to increase and exceed [Fleet's product maturity goals](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing) in the "MDM" product category. | Responsibility | Human(s) | @@ -57,7 +63,9 @@ The goal of the MDM group is to increase and exceed [Fleet's product maturity go > The [Slack channel](https://fleetdm.slack.com/archives/C03C41L5YEL), [kanban release board](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-mdm) for this product group is `#g-mdm`. + ## Making changes + Fleet's highest product ambition is to create experiences that users want. To deliver on this mission, we need a clear, repeatable process for turning an idea into a set of cohesively-designed changes in the product. We also need to allow [open source contributions](https://fleetdm.com/handbook/company#open-source) at any point in the process from the wider Fleet community - these won't necessarily follow this process. @@ -69,30 +77,49 @@ To make a change to Fleet: - Then, it will be [drafted](https://fleetdm.com/handbook/company/product-groups#drafting) (planned). - Next, it will be [implemented](https://fleetdm.com/handbook/company/product-groups#implementing) and [released](https://fleetdm.com/handbook/engineering#release-process). + ### Planned and unplanned changes + Most changes to Fleet are planned changes. They are [prioritized](https://fleetdm.com/handbook/product), defined, designed, revised, estimated, and scheduled into a release sprint _prior to starting implementation_. The process of going from a prioritized goal to an estimated, scheduled, committed user story with a target release is called "drafting", or "the drafting phase". Occasionally, changes are unplanned. Like a patch for an unexpected bug, or a hotfix for a security issue. Or if an open source contributor suggests an unplanned change in the form of a pull request. These unplanned changes are sometimes OK to merge as-is. But if they change the user interface, the CLI usage, or the REST API, then they need to go through drafting and reconsideration before merging. > But wait, [isn't this "waterfall"?](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) Waterfall is something else. Between 2015-2023, GitLab and The Sails Company independently developed and coevolved similar delivery processes. (What we call "drafting" and "implementation" at Fleet, is called "the validation phase" and "the build phase" at GitLab.) + +### Experimental features + +When a new feature is introduced it may be labeled as experimental. Experimental features are undergoing a rapid [incremental improvement and iteration process](https://fleetdm.com/handbook/company/why-this-way#why-lean-software-development) where new learnings may requires breaking changes. When we introduce experimental features, it is important that any API endpoints or configuration surface that may change in the future be clearly labeled as experimental. + +1. Apply the `~experimental` label to all associated user stories. +2. Set the optional `isExperimental` property to "yes" in [pricing-features-table.yml](https://github.com/fleetdm/fleet/blob/main/handbook/company/pricing-features-table.yml). +3. Make sure all API endpoints and configuration surface documentation includes the following message: + +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + + ### Breaking changes -For product changes that cause breaking API or configuration changes or major impact for users (or even just the _impression_ of major impact!), the company plans migration thoughtfully. That means the product department and E-group: -1. **Written:** Write a migration guide, even if that's just a Google Doc -2. **Tested:** Test out the migration ourselves, first-hand, as an engineer. -3. **Gamed out:** We pretend we are one or two key customers and try it out as a role play. -4. **Adapt:** If it becomes clear that the plan is insufficient, then fix it. -5. **Communicate:** Develop a plan for how to proactively communicate the change to customers. +For product changes that cause breaking API or configuration changes or major impact for users (or even just the _impression_ of major impact!), the company plans migration thoughtfully. If the feature was released as stable (not experimental), the product group and E-group: + +1. **Written:** Write a migration guide. +2. **Tested:** Test the migration thoroughly as engineers. +3. **Gamed out:** Pretend we are one or two key customers and try it out as a role play. +4. **Adapt:** If it becomes clear that the plan is insufficient, fix it. +5. **Communicate:** Create a plan for how to proactively communicate the change to customers. + +All of the steps above happen prior to any breaking changes to stable features being prioritized for implementation. -That all happens prior to work getting prioritized for the change. #### API changes + To maintain consistency, ensure perspective, and provide a single pair of eyes in the design of Fleet's REST API and API documentation, there is a single Directly Responsible Individual (DRI). The API design DRI will review and approve any alterations at the pull request stage, instead of making it a prerequisite during drafting of the story. You may tag the DRI in a GitHub issue with draft API specs in place to receive a review and feedback prior to implementation. Receiving a pre-review from the DRI is encouraged if the API changes introduce new endpoints, or substantially change existing endpoints. No API changes are merged without accompanying API documentation and approval from the DRI. The DRI is responsible for ensuring that the API design remains consistent and adequately addresses both standard and edge-case scenarios. The DRI is also the code owner of the API documentation Markdown file. The DRI is committed to reviewing PRs within one business day. In instances where the DRI is unavailable, the Head of Product will act as the substitute code owner and reviewer. + #### Changes to tables' schema + Whenever a PR is proposed for making changes to our [tables' schema](https://fleetdm.com/tables/screenlock)(e.g. to schema/tables/screenlock.yml), it also has to be reflected in our osquery_fleet_schema.json file. The website team will [periodically](https://fleetdm.com/handbook/marketing/website-handbook#rituals) update the json file with the latest changes. If the changes should be deployed sooner, you can generate the new json file yourself by running these commands: @@ -105,14 +132,18 @@ cd website > If a table is added to our ChromeOS extension but it does not exist in osquery or if it is a table added by fleetd, add a note that mentions it, as in this [example](https://github.com/fleetdm/fleet/blob/e95e075e77b683167e86d50960e3dc17045e3c44/schema/tables/mdm.yml#L2). + ### Drafting + "Drafting" is the art of defining a change, designing and shepherding it through the drafting process until it is ready for implementation. -The goal of drafting is to deliver software that works every time with less total effort and investment, without making contribution any less fun. By researching and iterating [prior to development](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), we design better product features, crystallize fewer bad, preemptive naming decisions, and achieve better throughput: getting more done in less time. +The goal of drafting is to deliver software that works every time with less total effort and investment, without making contribution any less fun. By researching and iterating [prior to development](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), we design better product features, crystallize fewer bad, preemptive naming decisions, and achieve better throughput: getting more done in less time. > Fleet's drafting process is focused first and foremost on product development, but it can be used for any kind of change that benefits from planning or a "dry run". For example, imagine you work for a business who has decided to swap out one of your payroll or device management vendors. You will probably need to plan and execute changes to a number of complicated onboarding/offboarding processes. + #### Drafting process + The DRI for defining and drafting issues for a product group is the product manager, with close involvement from the designer and engineering manager. But drafting is a team effort, and all contributors participate. A user story is considered ready for implementation once: @@ -125,19 +156,25 @@ A user story is considered ready for implementation once: > All user stories intended for the next sprint are estimated by the last estimation session before the sprint begins. This makes sure contributors have adequate time to complete the current sprint and provide accurate estimates for the next sprint. + #### Writing a good user story + Good user stories are short, with clear, unambiguous language. - What screen are they looking at? (`As an observer on the host details page…`) -- What do they want to do? (`As an observer on the host details page, I want to run a permitted query.`) +- What do they want to do? (`As an observer on the host details page, I want to run a permitted query.`) - Don't get hung up on the "so that I can ________" clause. It is helpful, but optional. - Example: "As an admin I would like to be asked for confirmation before deleting a user so that I do not accidentally delete a user." + #### Is it actually a story? + 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? + #### Defining "done" + To successfully deliver a user story, the people working on it need to know what "done" means. Since the goal of a user story is to implement certain changes to the product, the "definition of done" is written and maintained by the product manager. But ultimately, this "definition of done" involves everyone in the product group. We all collectively rely on accuracy of estimations, astuteness of designs, and cohesiveness of changes envisioned in order to deliver on time and without fuss. @@ -158,7 +195,9 @@ Things to consider when writing the "definition of done" for a user story: - **QA:** Changes are tested by hand prior to submitting pull requests. In addition, quality assurance will do an extra QA check prior to considering this story "done". Any special QA notes? - **Follow-through:** Is there anything in particular that we should inform others (people who aren't in this product group) about after this user story is released? For example: communication to specific customers, tips on how best to highlight this in a release post, gotchas, etc. + #### Providing context + User story issues contain an optional section called "Context". This section is optional and hidden by default. It can be included or omitted, as time allows. As Fleet grows as an all-remote company with more asynchronous processes across timezones, we will rely on this section more and more. @@ -176,14 +215,18 @@ Here are some examples of questions that might be helpful to answer: These questions are helpful for the product team when considering what to prioritize. (The act of writing the answers is a lot of the value!) But these answers can also be helpful when users or contributors (including our future selves) have questions about how best to estimate, iterate, or refine. + #### Initiate an air guitar session -Anyone in the product group can initiate an air guitar session. + +Anyone in the product group can initiate an air guitar session. 1. Initiate: Create a user story and add the `~air-guitar` label to indicate that it is going through the air guitar process. Air guitar issues are always intended to be designed right away. If they can't be, the requestor is notified via at-mention in the issue (that person is either the CSM or AE). +> An air guitar session may be used to design features that won't be delivered in the next 6 weeks. + 2. Prioritize: Bring the user story to [feature fest](https://fleetdm.com/handbook/product#rituals). If the user story is prioritized, proceed through the regular steps of specifying and designing as outlined in the drafting process. However, keep in mind that these are conceptual and may or may not proceed to engineering. -> An air guitar session may be needed before the next feature fest. In this case, the product group PM will prioritize the user story. +> An air guitar session may be needed before the next feature fest. In this case, the product group PM will prioritize the user story. 3. Review: Conduct an air guitar meeting where the idea or feature is discussed. Involve roles like the product manager, designer, and a sampling of engineers to provide various perspectives. @@ -198,9 +241,12 @@ Anyone in the product group can initiate an air guitar session. Air guitar sessions are timeboxed to ensure they are fast and focused. Documentation from this process may inform future user stories and can be invaluable when revisiting the idea at a later stage. While the air guitar process is exploratory in nature, it should be thorough enough to provide meaningful insights and data for future decision-making. + ### Implementing + #### Developing from wireframes + Please read carefully and [pay special attention](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach) to UI wireframes. Designs have usually gone through multiple rounds of revisions, but they could easily still be overlooking complexities or edge cases! When you think you've discovered a blocker, here's how to proceed: @@ -219,7 +265,9 @@ At Fleet, we prioritize [iteration](https://fleetdm.com/handbook/company#results After these considerations, if you still think you've found a blocker, alert the [appropriate PM](https://fleetdm.com/handbook/company/product-groups#current-product-groups) so that the user story can be brought back for [expedited drafting](https://fleetdm.com/handbook/product#expedited-drafting). Otherwise, make a [feature request](https://fleetdm.com/handbook/product#intake). + #### Sub-tasks + The simplest way to manage work is to use a single user story issue, then pass it around between contributors/asignees as seldom as possible. But on a case-by-case basis, for particular user stories and teams, it can sometimes be worthwhile to invest additional overhead in creating separate **unestimated sub-task** issues ("sub-tasks"). A user story is estimated to fit within 1 sprint and drives business value when released, independent of other stories. Sub-tasks are not. @@ -234,20 +282,28 @@ Sub-tasks: - are NOT the best place to post GitHub comments (instead, concentrate conversation in the top-level "user story" issue) - will NOT be looked at or QA'd by quality assurance + ## Outages + At Fleet, we consider an outage to be a situation where new features or previously stable features are broken or unusable. - Occurences of outages are tracked in the [Outages](https://docs.google.com/spreadsheets/d/1a8rUk0pGlCPpPHAV60kCEUBLvavHHXbk_L3BI0ybME4/edit#gid=0) spreadsheet. - Fleet encourages embracing the inevitability of mistakes and discourages blame games. - Fleet stresses the critical importance of avoiding outages because they make customers' lives worse instead of better. + ## Scaling Fleet + Fleet, as a Go server, scales horizontally very well. It’s not very CPU or memory intensive. However, there are some specific gotchas to be aware of when implementing new features. Visit our [scaling Fleet page](https://fleetdm.com/handbook/engineering/scaling-fleet) for tips on scaling Fleet as efficiently and effectively as possible. + ## Load testing + The [load testing page](https://fleetdm.com/handbook/engineering/load-testing) outlines the process we use to load test Fleet, and contains the results of our semi-annual load test. + ## Version support + To provide the most accurate and efficient support, Fleet will only target fixes based on the latest released version. In the current version fixes, Fleet will not backport to older releases. Community version supported for bug fixes: **Latest version only** @@ -258,7 +314,9 @@ Premium version supported for bug fixes: **Latest version only** Premium support for support/troubleshooting: **All versions** + ## Release testing + When a release is in testing, QA should use the Slack channel #help-qa to keep everyone aware of issues found. All bugs found should be reported in the channel after creating the bug first. When a critical bug is found, the Fleetie who labels the bug as critical is responsible for following the [critical bug notification process](https://fleetdm.com/handbook/engineering#notify-community-members-about-a-critical-bug) below. @@ -273,14 +331,16 @@ 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 `#g-mdm` or `#g-endpoint-ops` 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. +Once the critical bug is confirmed, a [priority label](https://fleetdm.com/handbook/company/communications#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). @@ -288,41 +348,33 @@ When a critical bug is identified, we will then follow the patch release process > 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). + +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). ### Making a request -To make a feature request or advocate for a feature request from a customer or community member, [create an issue](https://github.com/fleetdm/fleet/issues/new/choose) using the feature request template and attend the next scheduled 🎁🗣 Feature Fest meeting. +To make a feature request or advocate for a feature request from a customer or community member, [create an issue](https://github.com/fleetdm/fleet/issues/new/choose) using the feature request template and attend the next scheduled 🎁🗣 Feature Fest meeting. Requests are weighed from top to bottom while prioritizing attendee requests. This means that if the individual that added a feature request is not in attendance, the feature request will be discussed towards the end of the call if there's time. -To be acceptable for consideration, a request must: -- Have a clear proposed change -- Have a well-articulated underlying user need -- Specify the requestor (either internal stakeholder or customer or community user) - -To help the product team, other pieces of information can be optionally included: -- How would they solve the problem without any changes if pressed? -- How does this change fit into the requester's overall usage of Fleet? -- What other potential changes to the product have you considered? - -To ensure your request appears on the ["Feature Fest" board](https://app.zenhub.com/workspaces/-feature-fest-651b2962605ba29209324c57/board): -- Add the `~feature fest` label to your issue -- Add the relevant customer label (if applicable) - -To maximize your chances of having a feature accepted, requesters can visit the [🗣 Product office hours](#rituals) meeting to get feedback on requests prior to being accepted. ### How feature requests are evaluated + Digestion of these new product ideas (requests) happens at the **🎁🗣 Feature Fest** meeting. -At the **🎁🗣 Feature Fest** meeting, the DRI (Head of Product) weighs all requests on the board. When the team weighs a request, it is immediately prioritized or put to the side. +Before the **🎁🗣 Feature Fest** meeting, the [Customer renewals DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) goes through the "Inbox" column and removes customer requests that are not a high priority for the business. Stakeholders will be notified by the Customer renewals DRI. -Product Managers prioritize all potential product improvements worked on by Fleeties. Anyone (Fleeties, customers, and community members) are invited to suggest improvements. +All community and contributor requests (non-customer) are left in the inbox. A high priority customer request may be a request that's blocking a customer from getting their job done or a request that's critical for customer renewal. -- A _request is prioritized_ when the DRI decides it is a priority. When this happens, the team sets the request to be estimated within five business days. +Before the meeting, the Feature prioritization DRI adds requests from Fleet's roadmap that are planned for the next design sprint. The quarterly roadmap is in the [OKRs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=1846478041#gid=1846478041&range=393:419). + +At the **🎁🗣 Feature Fest** meeting, the Feature prioritization DRI weighs all requests in the inbox. When the team weighs a request, it is immediately prioritized or put to the side (not prioritized). + +- A _request is prioritized_ when the Feature prioritization DRI decides it is a priority. - A _request is put to the side_ when the business perceives competing priorities as more pressing in the immediate moment. -If a feature is not prioritized during a 🎁🗣 Feature Fest meeting, it only means the feature has been rejected _at that time_. Requestors will be notified by the Head of Product, and they can resubmit their request at a future meeting. +If a feature is not prioritized during a 🎁🗣 Feature Fest meeting, it only means the feature has been rejected _at that time_. Requestors will be notified by the Feature prioritization DRI, and they can resubmit their request at a future meeting. Requests are weighed by: - The completeness of the request (see [making a request](#making-a-request)) @@ -331,21 +383,17 @@ Requests are weighed by: - How well the request fits within Fleet's product vision and roadmap - Whether the feature seems like it can be designed, estimated, and developed in 6 weeks, given its individual complexity and when combined with other work already accepted -### Customer feature requests -The product team's goal is to prioritize 16 customer feature requests at Feature Fest, then take them from settled to shipped. The customer success team is responsible for providing the Head of Product a live count during the Feature Fest meeting. Product Operations is responsible for monitoring this KPI and raising alarms throughout the design and engineering sprints. -> Customer stories should be estimated at 1-3 points each to count as 1 request. If a feature request spans across multiple customers, it will be counted as the number of customers involved. ### After the feature is accepted -After the 🎁🗣 Feature Fest meeting, Product Operations will clear the Feature Fest board as follows: -**Prioritized features:** Remove `feature fest` label, add `:product` label, and assign the group Product Manager. -**Put to the side features:** Remove `feature fest` label and close the issue. -Group Product Managers will then develop user stories for the prioritized features. +After the "🎁🗣 Feature fest" meeting, the feature prioritization DRI will clear the ["🎁 Feature fest" board](https://github.com/fleetdm/fleet/issues#workspaces/feature-fest-651b2962605ba29209324c57/board) as follows: +**Prioritized features:** Remove the `~feature fest` label, create a new user story with the `:product` label, add a link from the original request to the user story, notify the requester, and move the user story to the "Ready" column in the drafting board. The user story will then be assigned to a [Product Designer](https://fleetdm.com/handbook/company/product-groups#current-product-groups) during the "Design sprint kick-off" ritual. +**Put to the side features:** Remove `feature fest` label and notify the requestor. -> The product team's commitment to the requester is that a prioritized feature will be delivered within 6 weeks or the requester will be notified within 1 business day of the decision to de-prioritize the feature. +> The product team's commitment to the requester is that a prioritized feature will be delivered within 6 weeks or the requester will be notified within 1 business day of the decision to de-prioritize the feature. Potential reasons for why a feature may be de-prioritized include: -- The work was not designed in time. Since Fleet's engineering sprints are 3 weeks each, this means that a prioritized feature has 3 weeks to be designed, approved, and estimated in order to make it to the engineering sprint. At the prioritization meeting, the perceived design complexity of proposed features will inevitably be different from the actual complexity. +- The work was not designed in time. Since Fleet's engineering sprints are 3 weeks each, this means that a prioritized feature has 3 weeks to be designed, approved, and estimated in order to make it to the engineering sprint. At the prioritization meeting, the perceived design complexity of proposed features will inevitably be different from the actual complexity. - This may be because other higher-priority design work took longer than expected or the work itself was more complex than expected - The was designed but was not selected for the sprint. When a new sprint starts, it is populated with bugs, features, and technical tasks. Depending on the size and quantity of non-feature work, certain features may not be selected for the sprint. @@ -374,14 +422,18 @@ You can read our guide to diagnosing issues in Fleet on the [debugging page](htt - [In engineering](https://fleetdm.com/handbook/company/product-groups#in-engineering) - [Awaiting QA](https://fleetdm.com/handbook/company/product-groups#awaiting-qa) + ### All bugs + - [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+is%3Aopen+label%3Abug). - **Bugs opened this week:** This filter returns all "bug" issues opened after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+label%3Abug+created%3A%3E%3DREPLACE_ME_YYYY-MM-DD). - **Bugs closed this week:** This filter returns all "bug" issues closed after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on Github](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+is%3Aclosed+label%3Abug+closed%3A%3E%3DREPLACE_ME_YYYY-MM-DD). + #### Inbox + Quickly reproducing bug reports is a [priority for Fleet](https://fleetdm.com/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks). When a new bug is created using the [bug report form](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title=), it is in the "inbox" state. At this state, the bug review DRI (QA) is responsible for going through the inbox and documenting reproduction steps, asking for more reproduction details from the reporter, or asking the product team for more guidance. QA has **1 business day** to move the bug to the next step (reproduced). @@ -390,27 +442,33 @@ For community-reported bugs, this may require QA to gather more information from Once reproduced, QA documents the reproduction steps in the description and moves it to the reproduced state. If QA or the engineering manager feels the bug report may be expected behavior, or if clarity is required on the intended behavior, it is assigned to the group's product manager. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+label%3Abug+label%3A%3Areproduce+sort%3Acreated-asc+). + #### Reproduced + QA has reproduced the issue successfully. It should now be transferred to engineering. -Remove the “reproduce” label, add the following labels: +Remove the “reproduce” label, add the following labels: 1. The relevant product group (e.g. `#g-endpoint-ops`, `#g-mdm`, `#g-digital-experience`). 3. The `~released bug` label if the bug is in a published version of Fleet, or `~unreleased bug` if it is not yet published. -2. The `:incoming` label indicates to the EM that it is a new bug. -3. The `:release` label will place the bug on the team's release board. +2. The `:incoming` label indicates to the EM that it is a new bug. +3. The `:release` label will place the bug on the team's release board. Once the bug is properly labeled, assign it to the [relevant engineering manager](https://fleetdm.com/handbook/company/product-groups#current-product-groups). (Make your best guess as to which team. The EM will re-assign if they think it belongs to another team.) [See on GitHub](https://github.com/fleetdm/fleet/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+label%3Abug+label%3A%3Aproduct%2C%3Arelease+-label%3A%3Areproduce+sort%3Aupdated-asc+). > **Fast track for Fleeties:** Fleeties do not have to wait for QA to reproduce the bug. If you're confident it's reproducible, it's a bug, and the reproduction steps are well-documented, it can be moved directly to the reproduced state. + #### In product drafting (as needed) + If a bug requires input from product the `:product` label is added, the `:release` label is removed, and the PM is assigned to the issue. It will stay in this state until product closes the bug, or removes the `:product` label and assigns to an EM. -#### In engineering + +#### In engineering + A bug is in engineering after it has been reproduced and assigned to an EM. If a bug meets the criteria for a [critical bug](https://fleetdm.com/handbook/engineering#critical-bugs), the `~critical bug` label is added, and the EM follows the [critical bug notification process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#critical-bug-notification-process). -During daily standup, the EM will filter the board to only `:incoming` bugs and review with the team. The EM will remove the `:incoming` label, prioritize the bug in the "Ready" coulmn, unassign themselves, and assign an engineer or leave it unassigned for the first available engineer. +During daily standup, the EM will filter the board to only `:incoming` bugs and review with the team. The EM will remove the `:incoming` label, prioritize the bug in the "Ready" coulmn, unassign themselves, and assign an engineer or leave it unassigned for the first available engineer. When fixing the bug, if the proposed solution requires changes that would affect the user experience (UI, API, or CLI), notify the EM and PM to align on the acceptability of the change. @@ -424,50 +482,36 @@ For Endpoint ops support on MDM bugs: - Remove the `#g-mdm` label and add `#g-endpoint-ops` label. - Add `~assisting g-mdm` to clarify the bug’s origin. -Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements). +Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements). + #### 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 the level of urgency. - -- 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 Fleet emergency. - - Response: Immediately stop other work to swarm the issue. Work 24/7 in shifts until resolved. - - Impact: Significant impact. May void current sprint. - -- 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 Fleet 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. - -- Urgent: `P2` - - Examples: A supported workflow is not functioning as intended, a newly drafted feature has an associated urgent Fleet 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). + ### The developer on-call rotation + See [the internal Google Doc](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) for the engineers in the rotation. Fleet team members can also subscribe to the [shared calendar](https://calendar.google.com/calendar/u/0?cid=Y181MzVkYThiNzMxMGQwN2QzOWEwMzU0MWRkYzc5ZmVhYjk4MmU0NzQ1ZTFjNzkzNmIwMTAxOTllOWRmOTUxZWJhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) for calendar events. -New developers are added to the on-call rotation by their manager after they have completed onboarding and at least one full release cycle. We aim to alternate the rotation between product groups when possible. +New developers are added to the on-call rotation by their manager after they have completed onboarding and at least one full release cycle. We aim to alternate the rotation between product groups when possible. > The on-call rotation may be adjusted with approval from the EMs of any product groups affected. Any changes should be made before the start of the sprint so that capacity can be planned accordingly. + ### Developer on-call responsibilities + - **Second-line response** The on-call developer is a second-line responder to questions raised by customers and community members. -The on-call developer is responsible for the first response to community pull requests. +The on-call developer is responsible for the first response to community pull requests. Customer Support Engineers are responsible for the first response to Slack messages in the [#fleet channel](https://osquery.slack.com/archives/C01DXJL16D8) of osquery Slack, and other public Slacks. The Customer Success group is responsible for the first response to messages in private customer Slack channels. @@ -492,7 +536,9 @@ Fleet's documentation for contributors can be found in the [Fleet GitHub repo](h 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. To achieve quick "first-response" times, you are encouraged to say something like "I don't know the answer and I'm taking it back to the team," or "I think X, but I'm confirming that with the team (or by looking in the code)." @@ -503,21 +549,16 @@ How to escalate: 2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@lukeheath` 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). Luke 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. A Slack reminder should notify the on-call of the handoff. Please do the following: -1. The new on-call developer should change the `@oncall` alias in Slack to point to them. In the - search box, type "people" and select "People & user groups." Switch to the "User groups" tab. - Click `@oncall`. In the right sidebar, click "Edit Members." Remove the former on-call, and add - yourself. +1. The new on-call developer should change the `@oncall` alias in Slack to point to them. In the search box, type "people" and select "People & user groups." Switch to the "User groups" tab. Click `@oncall`. In the right sidebar, click "Edit Members." Remove the former on-call, and add yourself. -2. Hand off newer conversations (Slack threads, issues, PRs, etc.). For more recent threads, the former on-call can unsubscribe from the thread, and the new on-call should subscribe. The former on-call should explicitly share each of - these threads and the new on-call can select "Get notified about new replies" in the "..." menu. - The former on-call can select "Turn off notifications for replies" in that same menu. It can be - helpful for the former on-call to remain available for any conversations they were deeply involved - in, so use your judgment on which threads to hand off. Anything not clearly handed off remains the responsibility of the former on-call developer. +2. Hand off newer conversations (Slack threads, issues, PRs, etc.). For more recent threads, the former on-call can unsubscribe from the thread, and the new on-call should subscribe. The former on-call should explicitly share each of these threads and the new on-call can select "Get notified about new replies" in the "..." menu. The former on-call can select "Turn off notifications for replies" in that same menu. It can be helpful for the former on-call to remain available for any conversations they were deeply involved in, so use your judgment on which threads to hand off. Anything not clearly handed off remains the responsibility of the former on-call developer. In the Slack reminder thread, the on-call developer includes their retrospective. Please answer the following: @@ -527,7 +568,9 @@ In the Slack reminder thread, the on-call developer includes their retrospective 3. How did you spend the rest of your on-call week? This is a chance to demo or share what you learned. -## Wireframes + +## 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) @@ -541,6 +584,10 @@ Use the 🧩 ["Design System (current)"](https://www.figma.com/file/8oXlYXpgCV1S Use `---`, with color `$ui-fleet-black-50` as the default UI for empty columns. +**Images** + +Simple icons (aka any images used in the icon [design system component](https://www.figma.com/design/8oXlYXpgCV1Sn4ek7OworP/%F0%9F%A7%A9-Design-system-(current)?node-id=12-2&t=iO2vXbQ9Sc1kFVEJ-1)) are exported as SVGs. All other images are exported as PNGs, following the [Fleet website image](https://github.com/fleetdm/fleet/tree/main/website/assets/images) naming conventions. + **Form behavior** Pressing the return or enter key with an open form will cause the form to be submitted. @@ -557,7 +604,7 @@ When including an external link, specify a [redirect on fleetdm.com](https://git **Tooltips** -All tooltips change the cursor to a question mark on hover. All tooltips have a solid background color. +All tooltips change the cursor to a question mark on hover. All tooltips have a solid background color. There are two types of tooltips. The two types of tooltips have some unique styles: @@ -596,13 +643,16 @@ When writing copy for CLI help pages use the following descriptions: $ fleetctl -h OPTIONS ---hosts Hosts specified by hostname, uuid, osquery_host_id or node_key that you want to target. ---host Host specified by hostname, uuid, osquery_host_id or node_key that you want to target. +--hosts Hosts specified by hostname, uuid, osquery_host_id or node_key that you want to target. +--host Host specified by hostname, uuid, osquery_host_id or node_key that you want to target. ``` + ## Meetings + ### User story discovery + User story discovery meetings are scheduled as needed to align on large or complicated user stories. Before a discovery meeting is scheduled, the user story must be prioritized for product drafting and go through the design and specification process. When the user story is ready to be estimated, a user story discovery meeting may be scheduled to provide more dedicated, synchronous time for the team to discuss the user story than is available during weekly estimation sessions. All participants are expected to review the user story and associated designs and specifications before the discovery meeting. @@ -622,8 +672,10 @@ All participants are expected to review the user story and associated designs an - Software Engineers: Clarifying questions and implementation details - Product Quality Specialist: Testing plan + ### 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). + +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). **Participants:** - Product Designer @@ -632,30 +684,34 @@ Design consultations are scheduled as needed with the relevant participants, typ **Sample agenda** - Review user story requirements - Review wireframes -- Discuss design input +- Discuss design input - Discuss implementation details -### Design reviews -Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and contributors proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API. -Product designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. The Head of Product Design and other participants review the changes quickly and give feedback, and then the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business. +### Design reviews + +Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team), [CTO](https://fleetdm.com/handbook/engineering#team), and contributors (most often Product Designers) proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API. + +Anyone at Fleet can attend as a shadow. Shadows are asked to leave feedback/comments in the agenda doc without interrupting the meeting. This helps the team iterate and move designs to ready for spec faster. + +> In addition to design reviews, Fleeties or community member can provide feedback asynchronously at any time by finding the GitHub issue (user story) associated with the designs and @ mentioning the assigned Product Designer in the comment section. + +Product Designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. + +After the meeting, the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business. Here are some tips for making this meeting effective: -- Bring 1 key engineer who has been helping out with the user story, when possible and helpful. - Say the user story out loud to remind participants of what it is. -- At the beginning of describing your change, indicate whether you are 70% sure you are 100% done, or are looking for early feedback. - Avoid explaining or showing multiple ways it could work. Show the one way you think it should work and let your work speak for itself. +- Make clear whether we're in "final review" or "feedback" mode: + - Final review: contributor is 70% sure the design is 100% done. + — Feedback: the design is not ready for final review, but contributor would like to get early feedback. - For follow-ups, repeat the user story, but show only what has changed or been added since the last review. +- Bring 1 key engineer who has been helping out with the user story, when possible and helpful. - Read Fleet's [best practices for meetings](https://fleetdm.com/handbook/company/communications#meetings). -> To allow for asynchronous participation, instead of attending, contributors can alternatively choose to add an agenda item to the "Product design review" meeting with a GitHub link. Then, the Head of Product Design will review during the meeting and provide feedback. Every "Product design review" is recorded and automatically transcribed to a Google Doc so that it is searchable by every Fleet team member. - -### Weekly bug review -QA has weekly check-in with product to go over the inbox items. QA is responsible for proposing “not a bug”, closing due to lack of response (with a nice message), or raising other relevant questions. All requires product agreement - -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. @@ -665,7 +721,9 @@ A chance for deeper, synchronous discussion on topics relevant across product gr - 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. @@ -678,32 +736,49 @@ This meeting is to disseminate engineering-wide announcements, promote cohesion - 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 + +### New customer promise(s) + +The Chief Revenue Office (CRO) schedules this meeting before Fleet commits to one or more new customer promises. It's meant to streamline communication and encourage getting the best product decisions. + +**Participants:** CRO, CEO, CTO, VP of Customer Success, and Head of Product Design. + +**Agenda:** +- Discuss new promises +- Decide if we know enough to say yes/no or go back to the customer for more digging ## 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) - Think fast and iterate. [🟢](https://fleetdm.com/handbook/company#results) - If it probably works, assume it's still broken. Assume it's your fault. [🔵](https://fleetdm.com/handbook/company#objectivity) - Speak up and have short toes. Write things down to make them complete. [🟣](https://fleetdm.com/handbook/company#openness) + ## Product design conventions -Behind every [wireframe at Fleet](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), there are 3 foundational design principles: + +Behind every [wireframe at Fleet](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), there are 3 foundational design principles: - **Use-case first.** Taking advantage of top-level features vs. per-platform options allows us to take advantage of similarities and avoid having two different ways to configure the same thing. Start off cross-platform for every option, setting, and feature. If we **prove** it's impossible, _then_ work backward making it platform-specific. - **Bridge the gap.** Implement enough help text, links, guides, gifs, etc that a reasonably persistent human being can figure it out just by trying to use the UI. - Even if that means we have fewer features or slightly lower granularity (we can iterate and add more granularity later), make it easy enough to understand. Whether they're experienced Mac admins people or career Windows folks (even if someone has never used a Windows tool) they should _"get it"_. + Even if that means we have fewer features or slightly lower granularity (we can iterate and add more granularity later), make it easy enough to understand. Whether they're experienced Mac admins people or career Windows folks (even if someone has never used a Windows tool) they should _"get it"_. - **Control the noise.** Bring the needs surface level, tuck away things you don't need by default (when possible, given time). For example, hide Windows controls if there are no Windows devices (based on number of Windows hosts). + ## Scrum at Fleet + Fleet product groups employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence. New tickets are estimated, specified, and prioritized on the roadmap: - [Roadmap](https://app.zenhub.com/workspaces/-roadmap-ships-in-6-weeks-6192dd66ea2562000faea25c/board) + ### Scrum items + Our scrum boards are exclusively composed of four types of scrum items: 1. **User stories**: These are simple and concise descriptions of features or requirements from the user's perspective, marked with the `story` label. They keep our focus on delivering value to our customers. Occasionally, due to ZenHub's ticket sub-task structure, the term "epic" may be seen. However, we treat these as regular user stories. @@ -716,26 +791,34 @@ Our scrum boards are exclusively composed of four types of scrum items: > Our sprint boards do not accommodate any other type of ticket. By strictly adhering to these four types of scrum items, we maintain an organized and focused workflow that consistently adds value for our users. + ## Sprints + Sprints align with Fleet's [3-week release cycle](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence). -On the first day of each release, all estimated issues are moved into the relevant section of the new "Release" board, which has a kanban view per group. +On the first day of each release, all estimated issues are moved into the relevant section of the new "Release" board, which has a kanban view per group. Sprints are managed in [Zenhub](https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible). To plan capacity for a sprint, [create a "Sprint" issue](https://github.com/fleetdm/confidential/issues/new/choose), replace the fake constants with real numbers, and attach the appropriate labels for your product group. + ### Sprint numbering + Sprints are numbered according to the release version. For example, for the sprint ending on June 30th, 2023, on which date we expect to release Fleet v4.34, the sprint is called the 4.34 sprint. + ### Sprint ceremonies + Each sprint is marked by five essential ceremonies: 1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint. -2. **Daily standup**: Every day, the team convenes for updates. During this session, each team member shares what they accomplished since the last standup, their plans until the next meeting, and any blockers they are experiencing. Standups should last no longer than fifteen minutes. If additional discussion is necessary, it takes place after the standup with only the required partipants. +2. **Daily standup**: Every day, the team convenes for updates. During this session, each team member shares what they accomplished since the last standup, their plans until the next meeting, and any blockers they are experiencing. Standups should last no longer than fifteen minutes. If additional discussion is necessary, it takes place after the standup with only the required partipants. 3. **Weekly estimation sessions**: The team estimates backlog items once a week (three times per sprint). These sessions help to schedule work completion and align the roadmap with business needs. They also provide estimated work units for upcoming sprints. The EM is responsible for the point values assigned to each item and ensures they are as realistic as possible. 4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review the next release. Engineers are allotted 3-10 minutes to showcase features, improvements, and bug fixes they have contributed to the upcoming release. We focus on changes that can be demoed live and avoid overly technical details so the presentation is accessible to everyone. Features should show what is capable and bugs should identify how this might have impacted existing customers and how this resolution fixed that. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).) 5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint. + ## Outside contributions + [Anyone can contribute](https://fleetdm.com/handbook/company#openness) at Fleet, from inside or outside the company. Since contributors from the wider community don't receive a paycheck from Fleet, they work on whatever they want. Many open source contributions that start as a small, seemingly innocuous pull request come with lots of additional [unplanned work](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) down the road: unforseen side effects, documentation, testing, potential breaking changes, database migrations, [and more](https://fleetdm.com/handbook/company/development-groups#defining-done). @@ -747,11 +830,13 @@ Thus, to ensure consistency, completeness, and secure development practices, no The following stubs are included only to make links backward compatible ##### Endpoint ops group -Please see [handbook/company/product-groups#endpoint-ops-group](https://fleetdm.com/handbook/company/product-groups#endpoint-ops-group) +Please see [handbook/company/product-groups/endpoint-ops-group](https://fleetdm.com/handbook/company/product-groups#endpoint-ops-group) ##### Air guitar Please see [handbook/company/initiate-an-air-guitar-session](https://fleetdm.com/handbook/company/product-groups#initiate-an-air-guitar-session) +##### High priority user stories and bugs +Please see [handbook/company/communications/high-priority-user-stories-and-bugs](https://fleetdm.com/handbook/company/communications#high-priority-user-stories-and-bugs) - + diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index 96fefc9427..909daf02e3 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -141,15 +141,6 @@ quoteAuthorProfileImageFilename: testimonial-author-dhruv-majumdar-48x48@2x.png quoteAuthorJobTitle: Director Of Cyber Risk & Advisory productCategories: [Vulnerability management, Endpoint operations] -- - quote: When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. - quoteImageFilename: logo-deputy-118x28@2x.png - quoteAuthorName: Harrison Ravazzolo - quoteAuthorProfileImageFilename: testimonial-author-harrison-ravazzolo-48x48@2x.png - quoteLinkUrl: https://www.linkedin.com/in/harrison-ravazzolo/ - quoteAuthorJobTitle: Lead platform and identity engineer - youtubeVideoUrl: https://www.youtube.com/watch?v=5W0q5yQE3R0 - productCategories: [Endpoint operations] - quote: Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didn’t expect. It spread so naturally, even our corporate and infrastructure teams want to run it. quoteAuthorName: Charles Zaffery diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index da8748cd09..6c5e89a942 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -12,6 +12,7 @@ Here are some of Fleet's decisions about the best way to work, and the reasoning ## Why open source? + Fleet's source code, website, documentation, company handbook, and internal tools are [public](https://github.com/fleetdm/fleet) and accessible to everyone, including engineers, executives, and end users. (Even [paid features](https://fleetdm.com/pricing) are source-available.) Meanwhile, the [company behind Fleet](https://twitter.com/fleetctl) is built on the [open-core](https://www.heavybit.com/library/video/commercial-open-source-business-strategies) business model. Openness is one of our core [values](https://fleetdm.com/handbook/company#values), and everything we do is [public by default](https://handbook.gitlab.com/handbook/values/#public-by-default). Even the [company handbook](https://fleetdm.com/handbook) is open to the world. @@ -31,6 +32,7 @@ Here are some of the reasons we build in the open: ## Why handbook-first strategy? + The Fleet handbook provides team members with up-to-date information about how to do things in the company. At Fleet, we make changes to the handbook first. That means, before any change to how we run the business is "live" or "official", it is first changed in the relevant [handbook pages](https://fleetdm.com/handbook) and [issue templates](https://github.com/fleetdm/confidential/tree/main/.github/ISSUE_TEMPLATE). @@ -43,6 +45,7 @@ To contribute to the handbook, click "Edit this page" and make your [edits in Ma ## Why read documentation? + There are three reasons for visiting [the docs](https://fleetdm.com/docs): - **Tire-kicking**: "I think this is cool, now is it something that I could ACTUALLY use? Does it ACTUALLY work? What all's in it? What links can I share with my colleagues to help them see what I'm seeing?" - **Committed learning**: "I've decided to learn this. I need a curriculum to get me there; with content that makes it as easy as possible, surface-level as possible. I want to learn how Fleet works and how to do all the things." @@ -61,19 +64,21 @@ Everyone [can contribute](https://fleetdm.com/handbook/company#openness) to Flee ## Why the emphasis on training? + Investing in people and providing generous, prioritized training, especially up front, helps contributors understand what is going on at Fleet. By making training a prerequisite at Fleet, we can: - help team members feel confident in the better decisions they make at work. - create a culture of helping others, which results in team members feeling more comfortable even if they aren’t familiar with the osquery, security, startup, or IT space. Here are a few examples of how Fleet prioritizes training: - the first 3 days at the company for every new team member are reserved for working on the tasks and training in their onboarding issue. -- during the first 2 weeks at the company, every new fleetie joins a **daily 1:1 meeting** with their manager to check in and see how they're doing, and if they have any questions or blockers. If the manager is not available for this meeting, the CEO (pending availability) or the Head of Business Operations will join this short daily meeting with them instead. +- during the first 2 weeks at the company, every new fleetie joins a **daily 1:1 meeting** with their manager to check in and see how they're doing, and if they have any questions or blockers. If the manager is not available for this meeting, the CEO (pending availability) or the Head of Digital Experience will join this short daily meeting with them instead. - In their first few days, every new fleetie joins: - - hands-on contributor experience training session with the Head of Business Operations where they share their screen, check the configuration of their tools, complete any remaining setup, and discuss best practices. - - a short sightseeing tour with the Head of Business Operations and (pending availability) Fleet's CEO to show them around and welcome them to the company. + - hands-on contributor experience training session with the Head of Digital Experience where they share their screen, check the configuration of their tools, complete any remaining setup, and discuss best practices. + - a short sightseeing tour with the Head of Digital Experience and (pending availability) Fleet's CEO to show them around and welcome them to the company. ## Why direct responsibility? + Like Apple and GitLab, Fleet uses the concept of [directly responsible individuals (DRIs)](https://about.gitlab.com/handbook/people-group/directly-responsible-individuals/) to know who is responsible for what. DRIs help us collaborate efficiently by knowing exactly who is responsible and can make decisions about the work they're doing. This saves time by eliminating a requirement for consensus decisions or political presenteeism, enables faster decision-making, and ensures a single individual is aware of what to do next. @@ -85,9 +90,8 @@ DRIs help us collaborate efficiently by knowing exactly who is responsible and c - **Multiple maintainers**: In some cases, multiple subject-matter experts called "maintainers" can merge changes to certain file paths, even though there is already a dedicated DRI configured as the "CODEOWNER". For examples of this, see the auto-approval flows configured as `sails.config.custom.githubRepoMaintainersByPath` and related configuration in [`website/config/custom.js`](https://github.com/fleetdm/fleet/blob/main/website/config/custom.js). - - ## Why do we use a wireframe-first approach? + Wireframing (usually as part of what Fleet calls ["drafting"](https://fleetdm.com/handbook/company/development-groups#making-changes)) provides a clear overview of page layout, information architecture, user flow, and functionality. The [wireframe-first approach](https://speakerdeck.com/mikermcneil/i-love-apis?slide=28) extends beyond what users see on their screens. Wireframe-first is also excellent for drafting APIs, config settings, CLI options, and even business processes. It's design thinking, applied to software development. @@ -109,8 +113,8 @@ Here's why Fleet uses a wireframe-first approach: - While the "wireframe first" practice is [still sometimes misunderstood](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall), today many modern high-performing teams now use a [wireframe-first methodology](https://speakerdeck.com/mikermcneil/i-love-apis), including [startups](https://www.forbes.com/sites/danwoods/2015/10/19/dont-get-ubered-apis-hold-key-to-digital-transformation/?sh=50112fea182c#:~:text=One%20recommendation%20that,deep%20experience) and [publicly-traded companies](https://about.gitlab.com/handbook/product-development-flow/#validation-phase-3-design). - ## Why do we use one repo? + At Fleet, we keep everything in one repo ([`fleetdm/fleet`](https://github.com/fleetdm/fleet)). Here's why: - One repo is easier to manage. It has less surface area for keeping content up to date and reduces the risk of things getting lost and forgotten. @@ -138,8 +142,8 @@ Besides the exceptions above, Fleet does not use any other repositories. Other > _**Tip:** In addition to the built-in search available for the public handbook on fleetdm.com, you can also [search any public AND non-public content, including issue templates, at the same time](https://github.com/search?q=org%3Afleetdm+path%3A.github%2FISSUE_TEMPLATE+path%3Ahandbook%2F+path%3Adocs%2F+foo&type=code)._ - ## Why not continuously generate REST API reference docs from javadoc-style code comments? + Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown. - Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we [present the information to the end-user](https://x.com/wesleytodd/status/1769810305448616185?s=46&t=4_cwTxqV5IXDLBvCm8KI6Q). @@ -153,21 +157,25 @@ Here are a few of the drawbacks that we have experienced when generating docs vi ## Why group Slack channels? + Groups (`g-*`) are organized around goals. Connecting people with the same goals helps them produce better results by fostering freer communication. Some groups align with teams in the org chart. Other groups, such as [product groups](https://fleetdm.com/handbook/company/development-groups), are cross-functional, with some group members who do not report to the same manager. Every group at Fleet maintains their own Slack channel, which all group members join and keep unmuted. Everyone else at Fleet is encouraged to mute these channels, using them only as needed. Each channel has a directly responsible individual responsible for keeping up with all new messages, even if they aren't explicitly mentioned (`@`). ## Why make work visible? + Work is tracked in [GitHub issues](https://github.com/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+). -Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. +Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-digital-experience-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. 1. **Intake:** Give people from anywhere in the world the ability to [request something](https://github.com/fleetdm/confidential/issues/new/choose) from a particular team, and give that team the ability to see and [respond quickly](https://fleetdm.com/handbook/company#results) to new requests. 2. **Planning:** Give the team's manager and other team members a way to plan the [next three-week iteration](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence) of what the team is working on. Provide a world (the kanban board) where the team has clarity, and the appropriate [DRI](https://fleetdm.com/handbook/company#why-direct-responsibility) can confidently [prioritize and plan changes](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) with enough context to make the right decisions. 3. **Shared to-do list:** What should I work on next? Who needs help? What important work is blocked? Is that bug fix merged yet? When will it be released? When will that new feature ship? What did I do yesterday? + ## Why agile? + Releasing software [🟢iteratively](https://fleetdm.com/handbook/company#results) gets changes and improvements into the hands of users faster and generally results in [🔵software that works](https://fleetdm.com/handbook/company#objectivity). This makes contributors fitter, happier, and more productive. We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's [development process](https://fleetdm.com/handbook/company/product-groups#making-changes): @@ -177,7 +185,7 @@ We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's 3. Deliver working software frequently, from a couple of weeks to a couple of months, with a preference to the shorter timescale. 4. Business people and developers must [work together daily](https://fleetdm.com/handbook/company/product-groups) throughout the project. 5. Build projects around motivated individuals. Give them the environment and support they need, and trust them to get the job done. -6. The most efficient and effective method of conveying information to and within a development team is [face-to-face conversation](https://fleetdm.com/handbook/business-operations#meetings). +6. The most efficient and effective method of conveying information to and within a development team is [face-to-face conversation](https://fleetdm.com/handbook/communications#meetings). 7. Working software is the primary measure of progress. 8. Agile processes promote sustainable development. The sponsors, developers, and users should be able to maintain a constant pace indefinitely. 9. Continuous attention to technical excellence and good design enhances agility. @@ -187,6 +195,7 @@ We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's ### Why scrum? + Scrum is an agile framework for software development that helps teams deliver high quality software faster. It emphasizes teamwork, collaboration, and continuous improvement to achieve business objectives. Here are some of the key reasons why [we use scrum at Fleet](https://fleetdm.com/handbook/engineering#scrum)): - Improved collaboration and communication: Scrum emphasizes teamwork and collaboration, which leads to better communication between team members and stakeholders. This helps ensure that everyone is aligned and working towards the same goals. - Flexibility and adaptability: Scrum allows teams to respond quickly to changing requirements and market conditions. By working in short sprints, teams can continuously adapt to new information and feedback, and adjust their approach as needed. @@ -194,7 +203,9 @@ Scrum is an agile framework for software development that helps teams deliver hi - Faster delivery of working software: Scrum helps teams deliver working software faster by breaking down the development process into manageable chunks that can be completed within a sprint. Stakeholders can see progress and provide feedback more quickly, which helps ensure the final product meets their needs. - Higher quality software: Scrum includes regular testing and quality assurance activities, which help ensure that the software being developed is of high quality and meets the required standards. + ### Why lean software development? + [Lean software development](https://en.wikipedia.org/wiki/Lean_software_development) is an iterative and incremental approach to software development that aims to eliminate waste and deliver value to customers quickly. It is based on the principles of [lean manufacturing](https://en.wikipedia.org/wiki/Lean_manufacturing) and emphasizes continuous improvement, collaboration, and customer focus. Lean development can be summarized by its seven principles: @@ -206,7 +217,9 @@ Lean development can be summarized by its seven principles: 6. Build integrity in: Build quality into the software by continuously testing, reviewing, and improving the code throughout the development process. 7. Optimize the whole: Optimize the entire process and focus on the system's overall performance rather than just individual parts to ensure the most efficient and effective use of resources. + ## Why a three-week cadence? + The Fleet product is released every three weeks. By syncing the whole company to this schedule, we can: - Keep all team members (especially those who aren't directly involved with the core product) aware of the current version of Fleet and when the next release is shipping. @@ -228,7 +241,9 @@ Why bother with all that? And why do it in this particular order? - **Better customer experience.** Understanding the impact of every production issue means we can reach out to affected users ASAP and acknowledge their challenge, showing them that Fleet takes quality and stability seriously. This kind of customer support is rare and memorable. - **It helps us prevent future outages.** By finding outages sooner, we incentivize ourselves to fix the root cause sooner. And by fixing bugs sooner, we prevent them from stacking and bleeding into one another, and we prevent ourselves from implementing future fixes and improvements on top of shaky foundations. This makes contributions less risky and reduces the number of outages. + ## Why make it obvious when stuff breaks? + At Fleet, we detect and fix bugs as quickly as possible. Breaking loudly means we can fix the break sooner and improve how fast and certain we are about making future changes. Especially in an all-remote environment, this provides contributors with discipline around quality and stability of the main branch. This is ["good annoying"](https://agilehope.blogspot.com/2014/12/diy-build-light-indicator.html). @@ -239,7 +254,9 @@ If that happens by mistake, first priority is merging a fix, then notifying the > Here is [an example of a deliberate decision to make broken images in Fleet fail more loudly](https://github.com/fleetdm/fleet/issues/12305#issuecomment-1671924257) so that they can't be overlooked, even though this might slow down short-term development. + ## Why keep issue templates simple? + At Fleet, we optimize for the person submitting the issue, not the person receiving it. We avoid making the submitter read anything. We prompt for as little information as possible. Why? @@ -251,6 +268,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why spend less? + - **Default to efficiency. Reward richly.** At Fleet, we celebrate success and reward hard work. But we do everyday things cheap. And that is very important, because it shapes the kind of people we hire, and the kind of expectations we set for the team about what "comfortable" feels like. - **Offsites are not rewards.** Day to day, Fleet does not look rich. Rich !== welcoming. The company is open, not closed. Work here means flexible collaboration, accessible people, and clear expectations. And a rich, exciting future worth working for. Not a rich, complacent baseline worth coasting for. - **Minimally viable comfort.** We stay at La Quintas by the train tracks every single time unless customers are coming into the room and we need more space. Even then, we accommodate in the spirit of _hospitality_, not to show off how well Fleet is doing. They'll know how well we're doing by how great the product is, how great the support is, and [how that makes them feel](https://fleetdm.com/handbook/company#purpose). They'll remember openness, flexibility, accessibility, and clarity in all of their interactions with the brand. Not the view from our hotel rooms. @@ -258,6 +276,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why don't we sell like everyone else? + Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach: - **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md). - **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win. @@ -268,39 +287,8 @@ Many companies encourage salespeople to ["spray and pray"](https://www.linkedin. - **Step up.** We look at the [🟠big picture](https://fleetdm.com/handbook/company#ownership). The goal is for the organization using Fleet to be successful, as well as the individuals who decide to use or buy the product. There are multiple versions of Fleet, and so many ways to "do" open-source security and IT. It is in the company's best interest to help engineers pick the right one; even if that's Fleet Free, or another solution altogether. We think about our customer's needs like they are our own. -## Why don't we track leads differently? -There are about as many "MQL" definitions as there are sales orgs in the world. Exaggerating here, but only somewhat. - -Fleet documents all KPI's with clear definitions that are simple to evaluate, easy to track, and highly iterable. - -- **Lead** == A "Lead" row in Salesforce. - -- **MQL** == a human from an in-ICP organization that meets these parameters when the lead is created: - - Their organization is _not_ already a Fleet customer - - Their organization is _not_ already considering buying Fleet as part of a qualified, mutually beneficial opportunity - - Our friend, the human, has chosen to open or widen their line of communication with the company. This could come from an event or or sending a contact form message requesting a call. - - Attendees at events are considered MQLs if they have done any of the following: - - Had a 5+ minute conversation or a badge scan at a physical event. - - RSVPed yes to extracurricular in-person side event (e.g. dinner or activity) OR attended the in-person side event and a Fleetie was able to track their attendance. - - RSVPed yes to a virtual event OR indicated intent by sending a follow-up email. - - Multiple people from the same org each count as _separate_ MQLs. - - When an account converts to an opportunity, all subsequent new leads created for that account are ***NOT*** MQLs (i.e. do not count towards "MQLs created".). If an opportunity is marked "closed lost", then it is _no longer_ open, so subsequent new leads associated with the prospective customer are considered MQLs again. - -- **Open MQL** == An MQL whose lead status is neither _"disqualified" nor "converted"_. - -- **SQL** == An MQL whose lead status in Salesforce has exceeded a _certain threshold_, for **any** reason, from **any** source (threshold TBD: we aren't reporting these yet in KPIs) - -- **Lead source** == where a lead came from. To determine attribution, we will consider the lead source. (e.g. sales-sourced vs. marketing-sourced vs. misc-sourced leads can be determined by looking at the lead source. No need to establish any other *QL or change these.) - - - Instead of saying _"outbound lead"_ or _"inbound lead"_, you can say _"a lead from a badge scan at an event"_ or _"a lead from a customer referral"_ or _"a lead from the website"_. - -- **Opportunity** == A _"Opportunity"_ row in Salesforce. - -- **Open opportunity** == An opportunity whose stats is not _"closed lost"_ nor _"closed won"_. - - - ## Why does Fleet support query packs? + As originally envisioned by Zach Wasserman and the team when creating osquery, packs are a way to import and export queries into (and out of!) any platform that speaks osquery, whether that's Fleet, [Security Onion](https://securityonionsolutions.com/), an EDR, or even Rapid7. Queries [should be portable](https://github.com/fleetdm/fleet/blob/f711e60de47c69ab8be5bc13cf73fedf88adc338/README.md#lighter-than-air) to minimize lock-in to particular tools. The "Packs" section of the UI that began in `kolide/fleet` c. 2017 was an early attempt to segment and target formations of hosts that share certain characteristics. This came with some difficulties with debugging and collaboration, since it could be hard to tell which queries were running on which hosts. It also made it harder to understand what performance impact running all those queries might cause. @@ -311,7 +299,9 @@ The first step was to add a simpler way to schedule queries, and tuck away the l Packs will always be supported in Fleet. + ## Why does Fleet use sentence case? + Fleet uses sentence case capitalization for all headings, subheadings, button text in the Fleet product, fleetdm.com, the documentation, the handbook, marketing material, direct emails, in Slack, and in every other conceivable situation. In sentence case, we write and capitalize words as if they were in sentences: @@ -322,7 +312,9 @@ As we use sentence case, only the first word is capitalized. But, if a word woul The reason for sentence case at Fleet is that everyone capitalizes differently in English, and capitalization conventions have not been taught very consistently in schools. Sentence case simplifies capitalization rules so that contributors can deliver more natural, even-looking content with a voice that feels similar no matter where you're reading it. + ## Why not use superlatives? + A superlative is an adjective or adverb that expresses the degree of a quality, such as "best," "worst," or "most beautiful." A superlative is a judgment or evaluation, [which only the customer can decide](https://twitter.com/mikermcneil/status/1686837625187930112). @@ -341,6 +333,7 @@ Avoid using too many unnecessary words or superlatives, so your writing is short ## Why does Fleet use "MDM on/off" instead of "MDM enrolled/unenrolled"? + MDM should be a capability, not a product category. In Fleet, the word "enrolled" means "the host shows up in the dashboard and API". @@ -351,7 +344,9 @@ Since Fleet is more than MDM, you can collect logs and health data on any comput That means you can collect logs from Linux servers or Windows factory workstations without enabling remote script execution on those computers, even if you're using script execution on your Macs. + ## Why not mention the CEO in Slack threads? + Everyone else who works at Fleet is expected to read (and reply or acknowledge with an emoji reaction) every time they're mentioned in Slack, even deep inside long threads. Now that the company has grown, the CEO gets mentioned in threads [too often](https://docs.google.com/document/d/1vK-Dy2BVrw7doYUzabOPyCiN4RfolWFgOKMm23l91s0/edit) to keep up with thread replies, even for threads he participates in. @@ -377,6 +372,7 @@ Thank you so much!" 🙇 #### Stubs + The following stubs are included only so that old links continue to work (for backwards compatibility.) ##### Reporting structure diff --git a/handbook/customer-success/README.md b/handbook/customer-success/README.md index 1aefe82038..0aeba57401 100644 --- a/handbook/customer-success/README.md +++ b/handbook/customer-success/README.md @@ -9,7 +9,7 @@ This handbook page details processes specific to working [with](#contact-us) and |:--------------------------------------|:------------------------------------------------------------------------------------------------------------------------| | VP of Customer Success | [Zay Hanlon](https://www.linkedin.com/in/zayhanlon/) _([@zayhanlon](https://github.com/zayhanlon))_ | Infrastructure Engineer | [Robert Fairburn](https://www.linkedin.com/in/robert-fairburn/) _([@rfairburn](https://github.com/rfairburn))_ -| Customer Support (CSE/CSA) | [Kathy Satterlee](https://www.linkedin.com/in/ksatter/) _([@ksatter](https://github.com/ksatter))_
    [Grant Bilstad](https://www.linkedin.com/in/grantbilstad/) _([@Pacamaster](https://github.com/Pacamaster))_
    [Dale Ribeiro](https://www.linkedin.com/in/daleribeiro/) _([@ddribeiro](https://github.com/ddribeiro))_
    Ben Edwards _([@edwardsb](https://github.com/edwardsb))_
    [Brock Walters](https://www.linkedin.com/in/brock-walters-247a2990/) _([@nonpunctual](https://github.com/nonpunctual))_ +| Customer Support (CSE/CSA) | [Kathy Satterlee](https://www.linkedin.com/in/ksatter/) _([@ksatter](https://github.com/ksatter))_
    [Rebecca Cowart](https://www.linkedin.com/in/rebeccaui/) _([@rebeccaui](https://github.com/rebeccaui))_
    [Dale Ribeiro](https://www.linkedin.com/in/daleribeiro/) _([@ddribeiro](https://github.com/ddribeiro))_
    Ben Edwards _([@edwardsb](https://github.com/edwardsb))_
    [Brock Walters](https://www.linkedin.com/in/brock-walters-247a2990/) _([@nonpunctual](https://github.com/nonpunctual))_ | Customer Success Manager (CSM) | [Jason Lewis](https://www.linkedin.com/in/jlewis0451/) _([@patagonia121](https://github.com/patagonia121))_
    [Michael Pinto](https://www.linkedin.com/in/michael-pinto-a06b4515a/) _([@pintomi1989](https://github.com/pintomi1989))_ @@ -29,7 +29,7 @@ The customer success department is directly responsible for ensuring that custom Occasionally, we will need to track public issues for customers and prospects who wish to remain anonymous on our public issue tracker. To do this: -1. The team member creating the issue will choose an appropriate minor planet name from this [Minor planets page](https://minorplanetcenter.net//iau/lists/MPNames.html) (alphabetical). +1. The team member creating the issue will choose an appropriate minor planet name from this [minor planets page](https://minorplanetcenter.net//iau/lists/MPNames.html) (alphabetical). 2. Create a label in the fleetdm/fleet and fleetdm/confidential repos which can be attached to current and future issues for the customer or prospect. As part of the label description in the fleetdm/confidential repo, add the customer or prospect name. This way, we maintain a confidential mapping of codename to customer or prospect. @@ -56,10 +56,20 @@ Before a routine customer call, the CSM prepares an agenda including the followi 4. Fill out all the required fields making sure to pick "Expansion" in the "Type" dropdown menu and then click "Save". +### Conduct a health check + +Health checks are conducted quarterly or bi-annually, in preparation for a quarterly business review (QBR). The purpose of a health check is to understand what features and functionality the customer is currently using in Fleet. This information will be used to provide guidance to the customer during their QBR. For more information around QBRs, please see the section below, titled "Conduct a quarterly business review". + +1. Work with your champion to schedule the health check at a time when their Fleet admins and daily users are available. Be sure to take notes, and record the meeting if possible. +2. During the meeting, ask the customer to share their screen and walk through their day-to-day use of Fleet. +3. Ask the customer questions about the features they are using to understand the "why" behind their use cases for Fleet. Try not to provide guidance directly on this call. +4. Review your notes after the meeting, and find areas of improvement that you can highlight to help your partner more thoroughly utilize Fleet and add your findings to the QBR deck. + + ### Conduct a quarterly business review (QBR) Business reviews are conducted quarterly or bi-annually to ensure initial success criteria completion, ongoing adoption, alignment on goals, and delivery of value as a vendor. Use the meeting to assess customer priorities for the coming year, review performance metrics, address any challenges and showcase value in upcoming and unutilized features. -1. Work with your champion to schedule the business review at a time thier stakeholders are available (typically 90 days after kickoff and again, 90 days before renewal). +1. Work with your champion to schedule the business review at a time their stakeholders are available (typically 90 days after kickoff and again, 90 days before renewal). 2. Collect usage metrics from the [usage data report](https://docs.google.com/spreadsheets/d/1Mh7Vf4kJL8b5TWlHxcX7mYwaakZMg_ZGNLY3kl1VI-c/edit?gid=0#gid=0) (internal Fleet document) and the following: - Optionally schedule a health check with day to day admins prior to the QBR to better understand how the product is being used and which features have been adopted. - Have a support engineer collect data on open and closed bugs from the previous quarter and highlight any P0 or P1 incidents along with a summary of the postmortem (search Unthread and GitHub for issues tagged with the customer codename and ':bug'). diff --git a/handbook/customer-success/customer-success.rituals.yml b/handbook/customer-success/customer-success.rituals.yml index 017063ae96..2c493ccf74 100644 --- a/handbook/customer-success/customer-success.rituals.yml +++ b/handbook/customer-success/customer-success.rituals.yml @@ -2,7 +2,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "zayhanlon" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues diff --git a/handbook/demand/README.md b/handbook/demand/README.md index 8f62bda026..69dd7a2d84 100644 --- a/handbook/demand/README.md +++ b/handbook/demand/README.md @@ -7,7 +7,7 @@ This handbook page details processes specific to working [with](#contact-us) and | Role | Contributor(s) |:-----------------------------------|:------------------------------------------------------------------------------------------------------------------------| -| Digital Marketing Manager | [Drew Baker](https://www.linkedin.com/in/andrew-baker-51547179/) _([@drewbakerfdm](https://github.com/drewbakerfdm))_ +| Head of Marketing | [Drew Baker](https://www.linkedin.com/in/andrew-baker-51547179/) _([@drewbakerfdm](https://github.com/drewbakerfdm))_ ## Contact us @@ -58,6 +58,49 @@ To propose an ad, or a change to an ad: 7. Create a calendar reminder to check ad performance two weeks from the date changes were made. +### Measure intent signals + +Intent signals help measure an individual's current level of engagement with the Fleet brand. Use the following steps to decide if: + +(a) A contact and/or account needs to be created/updated. + +(b) An account should be prioritized for [manual research](https://fleetdm.com/handbook/demand#research-an-account). + +(c) An account/contact would benefit from a sales conversation. + +(in order of how worthwhile it is to spend time looking at the intent signal) + +1. Accounts currently assigned to reps (i.e. pipeline + stage0 + pre-pipeline IQMs). +2. Accounts with trending psychological progression (as measured by fleetdm.com website signups (i.e. new contacts ± contacts that have increased their psystage to a certain point). +3. Accounts that fleeties have suggested to go after in ABM maneuver sheet. +4. [MacAdmins Slack traffic](https://macadmins.slack.com/archives/C0214NELAE7/p1722561481530559) in the #fleet AND #osquery channels (channel joins, posts, reactions, thread replies, thread reactions). +5. [LinkedIn page follows](https://www.linkedin.com/company/71111416/admin/analytics/followers/). +6. [GitHub stars to fleetdm/fleet](https://github.com/fleetdm/fleet/stargazers) from non-fleeties. + + +### Research an account + +Follow these steps to research an account and move it toward sales-readiness **after** discovering [relevant intent signals](https://fleetdm.com/handbook/demand#measure-intent-signals). + +1. Create the account in SalesForce if it doesn't already exist. +2. Update any incorrect, mistagged, or incomplete contacts already on the account and merge any duplicates that are found. Verify the following data is current for each existing contact: + - "Title" + - "Role" + - "Primary buying situation" + - "LinkedIn" + - "Psychological stage" + - "intent signals" +3. If you any reason that the account organization wouldn't benefit from a relationship with Fleet, change the "Type" to "Distraction" stop here. If you haven't disqualified the account at this point, update the "Marketing stage" to "Research-ready". +After an account is marked "[Research-ready](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000001LerV2AS/view?queryScope=userFolders)". + +1. Research missing contacts and add them to salesforce if they are real by using the [ABM maneuvers spreadsheet](https://docs.google.com/spreadsheets/d/1ijtBKTjPg_AodnKEZY0ivia70ttDR3VMURT8rpYwYiw/edit?gid=0#gid=0) to generate a Sales Nav search. Make sure they have "role", "buying situation", "linkedinUrl", "psychological stage", "intent signals" completely filled out and correct. +2. For "Contact source" for any new contacts, use "Manual research". +3. Rank the account in terms of closability and fit based on what we see from it and its contacts. Mark any account that is not a fit as "Distraction" instead of "Prospect". +4. Research and discover mutual connections between fleeties and Mac admin community members within those contacts to help determine fit. +5. Check Snitcher activity for the account and the psystages of its contacts in Salesforce. +6. Update the "marketing stage" AND "type" accordingly (qualify or disqualify based on whether the contacts look good). Start running ABM ads on the account if moving it to "Ads running" for a total of 60 days otherwise, stop them if moving it out of "Ads running". + + ### Promote a post on LinkedIn 1. Create a classic campaign under ["Experiments"](https://www.linkedin.com/campaignmanager/accounts/509911695/campaigns?campaignGroupIds=%5B678398233%5D) following the YYYY-MM-DD.buying-situation - ad description with a goal of website visits or engagement to run for two weeks. @@ -66,9 +109,24 @@ To propose an ad, or a change to an ad: 4. Launch campaign once approved. +### Settle content strategy + +The Head of Marketing is the DRI for deploying Fleet's outward-facing content. The content schedule is settled significantly in advance to provide ample time for strategy and planning. Use the following steps to settle content strategy: + +1. Using the [content calendar](https://docs.google.com/spreadsheets/d/1KUMsb5OkAsCBQHGkGnNoj__UCPJ7Vbhk1LaEWGEARsg/edit?gid=1931288160#gid=1931288160), propose the content that Fleet will produce in the current quarter, and the strategy behind that content, including: + - Content type and title (e.g. "Article: Fleet takes bacon to new heights with flying pigs release"). + - Create date: The date by which the DRI will start crafting the content. + - Release date: The date by which the content will be complete and finalized. + - Primary buying situation: The intended audience. + - DRI: Person(s) responsible for the project management of this content. + - Author: Person(s) responsible for the creation of this content. + - Related event?: Related community or Fleet event, if any. +2. Attend a 30m meeting with Fleet's Client Platform Engineer & Community Advocate, CTO, and CEO to review and settle the proposed content. + + ### Settle event strategy -The Head of Demand is the DRI for deploying Fleet's event budget, and events are settled significantly in advance to provide ample time for strategy and planning. Fleet's [Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team) is the DRI for executing Fleet events efficiently, on-brand, and on-strategy. +The Head of Marketing is the DRI for deploying Fleet's event budget, and events are settled significantly in advance to provide ample time for strategy and planning. Fleet's [Client Platform Engineer & Community Advocate](https://fleetdm.com/handbook/engineering#team) is the DRI for executing Fleet events efficiently, on-brand, and on-strategy. 1. Using the [event strategy workbook](https://docs.google.com/spreadsheets/d/1YQXAX2Q_WnGkAwMYjMbQpV3nbCj7gOBbv7Y0u4twxzQ/edit#gid=1411322737), propose the events that Fleet will attend in the next 6 months, and the strategy for those events, including: - target buying situation of the audience @@ -78,11 +136,31 @@ The Head of Demand is the DRI for deploying Fleet's event budget, and events are - all event materials, including printouts, banners, swag given out, and even the clothing worn by fleeties - estimated budget, including sponsorship or airfare, and lodging for attendees 2. Set up and attend a 30m meeting with the Fleet's Client Platform Engineer & Community Advocate and CEO: - - First during this meeting, the Head of Demand proposes an event issue for each of the **_current quarter's_** events to get input and any new information or changes from Fleet's Client Platform Engineer & Community Advocate and CEO. (Events for the current quarter were already decided in a previous event strategy session, so Fleet does not make changes except in extreme circumstances.) + - First during this meeting, the Head of Marketing proposes an event issue for each of the **_current quarter's_** events to get input and any new information or changes from Fleet's Client Platform Engineer & Community Advocate and CEO. (Events for the current quarter were already decided in a previous event strategy session, so Fleet does not make changes except in extreme circumstances.) - Next, decide which events in the **_following quarter_** the company will invest time or money into. This includes any event that Fleet pays to send someone to or to sponsor, and even events where Fleet's only involvement is that a fleetie will be giving a talk or otherwise representing the brand. - Finally, qualify or disqualify any newly-entered event ideas by either verifying and setting the buying situation, or removing the event idea from the spreadsheet. +### Upload contacts to Salesforce after an event + +1. [Create a new lead source](https://fleetdm.lightning.force.com/lightning/setup/ObjectManager/Contact/FieldsAndRelationships/LeadSource/view) with naming convention "[Retired]Events - {Event name}". +2. Add the new lead source name to the .csv of leads before uploading to Salesforce. + a. Add a new column header labeled "Lead source" and add the new lead source name to each row in the CSV. + + +3. Navigate to the [contact import wizard](https://fleetdm.lightning.force.com/one/one.app#eyJjb21wb25lbnREZWYiOiJvbmU6YWxvaGFQYWdlIiwiYXR0cmlidXRlcyI6eyJhZGRyZXNzIjoiL2RhdGFJbXBvcnRlci9kYXRhSW1wb3J0ZXIuYXBwP29iamVjdFNlbGVjdGlvbj1BY2NvdW50In0sInN0YXRlIjp7fX0%3D): + a. Select the standard object "Accounts and Contacts". + b. Select "Add new and update existing records" (Do not change the matching rules). + c. Upload the CSV. + d. Verify the data is mapped to the correct Salesforce fields and start the Import. + + +### Follow up after an event + +1. Email relevant information according to the event buying situation, but refer to the original lead list in [Google Drive](https://drive.google.com/drive/u/0/folders/1uXf95V6CHKHnqxRc9iQr0a0FnTZk3bXR) for those who asked for contact. +2. If feedback is present in the original event CSV, manually add any worthwhile feedback to the contact description in Salesforce. + + ### 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. @@ -138,7 +216,7 @@ There are many times in which community members, customers, and contributors are - Reach out to the contributor to thank them for their contribution - Consider sharing the contribution on social media - Ask if we could send the contributor any swag -- If yes, follow the steps to fufuill a swag request. +- If yes, follow the steps to fulfill a swag request. ### Run a new ad or change an existing ad @@ -150,7 +228,6 @@ Any changes to the current running ads visible to a user, including designs, key > **Do changes to keywords or targeting require a design review?** Currently, all changes to these things require discussion with our product marketer. - ### Engage with the community Public conversations on social media create valuable opportunities for contributors to answer technical questions and collect feedback. diff --git a/handbook/demand/demand.rituals.yml b/handbook/demand/demand.rituals.yml index f64b087da0..dc1ae27b47 100644 --- a/handbook/demand/demand.rituals.yml +++ b/handbook/demand/demand.rituals.yml @@ -9,7 +9,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "mikermcneil" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # « Enable automation of GitHub issues @@ -22,6 +22,13 @@ description: "https://fleetdm.com/handbook/demand#settle-event-strategy" moreInfoUrl: "https://fleetdm.com/handbook/demand#settle-event-strategy" dri: "Drew-P-drawers" +- + task: "🫧 Pipeline sync" + startedOn: "2024-08-29" + frequency: "Weekly" + description: "Allign with CRO and AEs on pipeline processes and incoming leads" + moreInfoUrl: "" + dri: "Drew-P-drawers" - task: "Optimize ads" startedOn: "2024-02-26" @@ -50,6 +57,20 @@ description: "Every release cycle, upload the ☁️🌈 Sprint demos video to YouTube" moreInfoUrl: "https://fleetdm.com/handbook/demand#upload-to-youtube" dri: "Drew-P-drawers" +- + task: "Measure intent signals" + startedOn: "2024-08-09" + frequency: "Daily" + description: "Measure intent signals and update SalesForce" + moreInfoUrl: "https://fleetdm.com/handbook/demand#measure-intent-signals" + dri: "Drew-P-drawers" +- + task: "Research accounts" + startedOn: "2024-08-09" + frequency: "Daily" + description: "Research SalesForce accounts and begin ABM ads" + moreInfoUrl: "https://fleetdm.com/handbook/demand#warm-up-actions" + dri: "Drew-P-drawers" # - # task: "Propose a fleet event" # startedOn: "2023-10-02" diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index b4bc7db24a..1fec6328ca 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -7,29 +7,238 @@ This page details processes specific to working [with](#contact-us) and [within] | Role | Contributor(s) |:--------------------------------|:----------------------------------------------------------------------| +| [CEO](https://fleetdm.com/handbook/company/leadership#ceo-flaws) | [Mike McNeil](https://www.linkedin.com/in/mikermcneil) _([@mikermcneil](https://github.com/mikermcneil))_ +| Head of People / HR / Legal | See [CEO](https://www.fleetdm.com/handbook/digital-experience#team) +| Apprentice to the CEO | See [Head of Digital Experience](https://www.fleetdm.com/handbook/digital-experience#team) | Head of Digital Experience | [Sam Pfluger](https://www.linkedin.com/in/sampfluger88/) _([@sampfluger88](https://github.com/sampfluger88))_ +| Apprentice | [Savannah Friend](https://www.linkedin.com/in/savannah-friend-2b1a53148/) _([@sfriendlee](https://github.com/sfriendlee))_ | Head of Design | [Mike Thomas](https://www.linkedin.com/in/mike-thomas-52277938) _([@mike-j-thomas](https://github.com/mike-j-thomas))_ | Software Engineer | [Eric Shaw](https://www.linkedin.com/in/eric-shaw-1423831a9/) _([@eashaw](https://github.com/eashaw))_ -| Apprentice to the CEO | See [Head of Digital Experience](https://www.fleetdm.com/handbook/digital-experience#team) -| Apprentice | [Savannah Friend](https://www.linkedin.com/in/savannah-friend-2b1a53148/) _([@sfriendlee](https://github.com/sfriendlee))_ +| Contracts and Compliance Engineer | [Nathan Holliday](https://www.linkedin.com/in/nathanael-holliday/) _([@hollidayn](https://github.com/hollidayn))_ + ## Contact us - To **make a request** of this department, [create an issue](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=digital-experience-request.md&title=TODO%3A+) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in the [#g-digital-experience](https://fleetdm.slack.com/archives/C058S8PFSK0) Slack channel. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-digital-experience-6451748b4eb15200131d4bab/board) for this department, including pending tasks and the status of new requests. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-digital-experience-6451748b4eb15200131d4bab/board?sprints=none) for this department, including pending tasks and the status of new requests. - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. ## Responsibilities -The Digital Experience department is directly responsible for the framework, content design, and technology behind Fleet's remote work culture, including fleetdm.com, the handbook, issue templates, UI style guides, internal tooling, Zapier flows, Docusign templates, key spreadsheets, and project management processes. +The Digital Experience department is directly responsible for the culture, training, framework, content design, and technology behind Fleet's remote work culture, including fleetdm.com, the handbook, issue templates, UI style guides, internal tooling, Zapier flows, Docusign templates, key spreadsheets, contracts, compliance, receiving and responding to legal notices, SOC2, deal desk, project management processes, human resources, benefits, opening positions, compensation planning, onboarding, and offboarding. + +> _**Note:**: Commission planning, taxes, state unemployment insurance filings, business insurance, Delaware registered agent and franchise taxes, virtual mailbox, company phone number, and other adjacent areas of responsibility are run by [the Finance department](https://fleetdm.com/handbook/finance)._ > _**Note:** If a user story involves only changes to fleetdm.com, without changing the core product, then that user story is prioritized, drafted, implemented, and shipped by the [Digital Experience](https://fleetdm.com/handbook/digital-experience) department. Otherwise, if the story **also** involves changes to the core product **as well as** fleetdm.com, then that user story is prioritized, drafted, implemented, and shipped by [the other relevant product group](https://fleetdm.com/handbook/company/product-groups#current-product-groups), and not by `#g-digital-experience`._ -### QA a change to fleetdm.com +### Access a background check +All Fleet team members undergo a background check provided through [Vetty](https://vetty.co/). Only the most recent background checks appear on the home page of Vetty's dashboard. To access a complete list of background checks run in Vetty, scroll down to the bottom of the candidates page and click "View Historical". + + +### Convert a Fleetie to a consultant + +If a Fleetie decides they want to move to being a [consultant](https://fleetdm.com/handbook/company/leadership#consultants), either the Fleetie or their manager need to create a [custom issue for the Digital Experience team](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Request%3A+_______________________) to notify them of the change. +Once notified, Digital Experience takes the following steps: +1. Confirm the following details with the Fleetie: + - Date of change + - Term of consultancy (time period) + - Hours/capacity expected (hours per week or month) + - Confirm hourly rate +2. Once details are confirmed, use the information given to create the consulting agreement for the Fleetie (either in docusign (US-based) or via Plane (international)), and send to their personal email for signature. Once signed, save in Fleetie's [employee file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +3. Schedule the Fleetie's final day in HRIS (Gusto or Plane). +4. Update final day in ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. +5. Create an [offboarding issue](https://github.com/fleetdm/classified/blob/main/.github/ISSUE_TEMPLATE/%F0%9F%9A%AA-offboarding-____________.md) for the Fleetie converting to a consultant, and confirm with their manager if there is a need to retain any tools or access while they are a consultant (default to removing all access from Fleet email, and migrating to personal email for Slack and other tools unless there is a business case to retain the Fleet email and associated tool access). +6. Follow the offboarding issue for next steps, including communicating to teammates and updating equity plan. + + +### Inform managers about hours worked + +Every Friday at 2:00 PM CT, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location. + +Here's how: + +1. Consultants submit their hours through Gusto (US consultants) or Plane.com (international consultants) and require DRI approval (generally their manager) for hours worked. Find the DRI using the [Digital Experience KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). +2. Send the teammate's DRI a direct message in Slack with a screenshot of the HRIS portal, showing hours logged since last Saturday at midnight, and ask them to confirm the hours are expected. Ensure the screenshot does not include compensation information. + - For international teammates, they cannot enter hours weekly in Plane.com, so you will need to request the hours worked from them in order to have the DRI approve them. +3. The following Monday, check for updates to logged hours and ensure the KPI sheet aligns with HRIS records. + - If there are discrepancies between what was previously reported, reconfirm logged hours with the teammate's DRI and update the KPI sheet to reflect the correct amount. + + +### 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. + + +### Update personnel details +When a Fleetie, consultant or advisor requests an update to their personnel details (name, location, phone, etc), follow these steps to ensure accurate representation across systems. +1. Team member submits a [custom issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Request%3A+_______________________) to update their personnel details (or Digital Experience team creates if the request comes via email or is sensitive and needs a classified issue). + - If change is for a primary identification or contact method, ask for evidence of change and capture in [employee's personnel file](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +2. Digital Experience makes change to HRIS (Gusto or Plane) to reflect change. + - Note: if making the change requires follow up steps, resolve those steps to action the change. +3. Once change is effected in HRIS, Digital Experience makes changes to ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) spreadsheet. +4. If required, Digital Experience makes any relevant changes to [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). +5. If required, Digital Experience makes any relevant changes to the ["🗺️ Geographical factors"](https://docs.google.com/spreadsheets/d/1rCVCs-eOo-VSEG7fPLgdq5l7oSaActl5bewaWP7PnSE/edit#gid=1533353559) spreadsheet and follows through on any action items involving tax implications (i.e. registering with a new state for employer taxes). +6. If required, Digital Experience also makes changes to other core systems (e.g: creating a new email alias in google workspace; updating details in Carta; etc). +7. The change is now actioned, notify the team member and close the issue. + +> Note: if the Fleetie is US based and has a qualifying life event that impacts benefit coverage, they can [follow the Gusto steps](https://support.gusto.com/article/100895878100000/Change-your-benefits-with-a-qualifying-life-event) to update their coverage elections. + + +### Change a Fleetie's job title +When Digital Experience receives notification of a Fleetie's job title changing, follow these steps to ensure accurate recording of the change across our systems. +1. Update ["🧑‍🚀 Fleeties"](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0): + - 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. +2. Update the departmental handbook page with the change of job title +3. [Prepare salary benchmarking information](#prepare-salary-benchmarking-information) to determine whether the teammate's current compensation aligns with the benchmarks of the new role. + - If the benchmark is significantly different, take the steps to [update a team member's compensation](#prepare-salary-benchmarking-information). +4. Update the relevant payroll/HRIS system. + - For updating Gusto (US-based Fleeties): + - Login to Gusto and navigate to "People > Team members". + - Find the Fleetie and select them to see their profile page. + - 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 Digital Experience receives notification of a Fleetie's manager changing, follow these steps to ensure correct recording in our systems. +1. Update [🧑‍🚀 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 whose 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. +2. If the person's department is changing, then update both departmental handbook pages to move the person to their new department: + - Remove the person from the "Team" section of the old department and add them to the "Team" section of the new department. +3. If the person's level of confidential access will change along with the change to their manager, then update that level of access: + - Update Google Workspace to make sure this person lives in the correct Google Group, removing them from the old and/or adding them to the new. + - Update 1password to remove this person from old vaults and/or add them to new vaults. + - For a team member moving from "classified" to "confidential" access, check Gusto, Plane, and other systems to remove their access. + +> **Note:** The Fleeties spreadsheet is the source of truth for who everyone's manager is and their job titles. + +### Recognize employee workiversaries + +At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/handbook/company/communications#workiversaries). To ensure this happens, take the following steps: + +1. Bimonthly, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the following two months. +2. Post in the #help-classifed Slack channel and cc the Head of Digital Experience. Use the following template: + + + ``` + [Month] + [workiversary date (DD-MMM)] - [teammate name] - [number of years at Fleet] + ``` + + + The Head of Digital Experience will also use this post to update the [All hands](https://fleetdm.com/handbook/company/communications#all-hands) deck. +3. On the day prior to a workiversary, send the teammate’s manager a DM on Slack: + + + ``` + Hey! Just a heads up, tomorrow is [teammate’s name] [number of years at Fleet] workiversary at Fleet. + Digital Experience can post something in the #random channel to recognize them, would you like to make that post instead? + ``` + + > If a manager elects to post and hasn't done so by 2pm ET on the day of the workiversary, send them a friendly reminder and offer to post instead. + +4. If the manager has deferred to Digital Experience, schedule a Slack post for the following day to recognize the teammate's contributions at Fleet. If you’re unsure about what to post, take a look at what’s been [posted previously](https://docs.google.com/document/d/1Va4TYAs9Tb0soDQPeoeMr-qHxk0Xrlf-DUlBe4jn29Q/edit). + + + +### Prepare salary benchmarking information +1. 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. +2. Copy the template text and paste at the end of the document. +3. Fill in details as required, pulling from [🧑‍🚀 Fleeties spreadsheet](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) and [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) as required. +4. Use the teammate's information to benchmark in [Pave](https://www.pave.com/) (login details in 1Password). You can pattern match from previous benchmarking entries, and include all company assumtions. Add the direct link to the Pave benchmark. + + +### Update a team member's compensation + +To [change a teammate's compensation](https://fleetdm.com/handbook/company/communications#compensation-changes), follow these steps: +1. Create a copy of the ["Values assessment" template](https://docs.google.com/spreadsheets/d/1P5TyRV2v-YN0aR_X8vd8GksKcr3uHfUDdshqpVzamV8/edit?usp=drive_link) and move it to the teammate's [personnel folder in Google Drive](https://drive.google.com/drive/folders/1UL7o3BzkTKnpvIS4hm_RtbOilSABo3oG?usp=drive_link). +2. Share the values assessment document with the manager and ask them to perform the values assessment. +3. Once the values assessment is complete, [prepare salary benchmarking information](#prepare-salary-benchmarking-information) and notify the Head of Digital Experience so the compensation change can be added to the e-group agenda for discussion amongst Fleet leadership. + - If the teammate's manager is not part of the e-group, the Head of Digital Experience will ensure they're included in the discussion at e-group as well. +4. Once compensation decisions have been finalized, the Head of Digital Experience will post in slack to `#help-classified` to confirm the decisions have been recorded in ["¶¶ 💌 Compensation decisions (offer math)"](https://docs.google.com/document/d/1NQ-IjcOTbyFluCWqsFLMfP4SvnopoXDcX0civ-STS5c/edit#heading=h.slomq4whmyas). +5. Send the teammates manager a Slack DM to determine who will communicate the decision to the teammate. +6. Update the respective payroll platform (Gusto or Plane) by navigating to the personnel page, selecting salary field, and updating with an effective date that makes the next payroll. +7. Update the [equity spreadsheet](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit?usp=sharing) (internal doc) by copying existing OTE to the bottom of the "Notes" cell, updating the OTE column with the new compensation information, and updating the "Last compensation change" column with the effective date from payroll platform. +8. Calculate the monthly burn rate increase percentage and notify the CEO via a Slack DM. + +> If the company decides on an additional equity grant as part of a compensation change, note the previous equity and new situation in detail in the "Notes" column of the equity plan. Update the "Grant started?" column to "todo" which adds it to the queue for the next time grants are processed (quarterly). + + +### Review Fleet's US company benefits + +Annually, around mid-year, Fleet will be prompted by Gusto to review company benefits. The goal is to keep changes minimal. Follow these steps: +1. Log in to your [Gusto admin account](https://gusto.com/). +2. Navigate to "Benefits" and select "Renewal survey". +3. Complete the survey questions, aiming for minimal changes. +4. Approximately 2-3 months after survery completion, Gusto will suggest plans based on Fleet's responses. Choose plans with minimal changes. +5. Gusto will offer these plans to employees during open enrollment, with new coverage starting 3-4 weeks afterward. + +### Grant equity +Equity grants for new hires are queued up as part of the [hiring process](https://fleetdm.com/handbook/digital-experience#hiring), then grants and consents are [batched and processed quarterly](https://github.com/fleetdm/confidential/issues/new/choose). + +Doing an equity grant involves: +- Executing a board consent +- The recipient and CEO signing paperwork about the stock options +- Updating the number of shares for the recipient in the equity plan +- Updating Carta to reflect the grant + +For the status of stock option grants, exercises, and all other _common stock_ including advisor, founder, and team member equity ownership, see [Fleet's equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0). For information about investor ownership, see [Carta](https://app.carta.com/corporations/1234715/summary/). + +> Fleet's [equity plan](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) is the source of truth, not Carta. Neither are pro formas sent in an email attachment, even if they come from lawyers. +> +> Anyone can make mistakes, and none of us are perfect. Even when we triple check. Small mistakes in share counts can be hard to attribute, and can cause headaches and eat up nights of our CEO's and operations team's time. If you notice what might be a discrepancy between the equity plan and any other secondary source of information, please speak up and let Fleet's CEO know ASAP. Even if you're wrong, your note will be appreciated. + + +### Review an NDA +We need to review an NDA anytime a vendor, customer or other party wants to: +- Use their own NDA rather than Fleet's standard NDA, or +- "Redline" (modify) Fleet's NDA by removing, adding or altering its terms. + +We should always seek to use Fleet's own NDA first, without alteration. + +When reading an NDA, we want to pay close attention to the following: +- We want to be sure that the confidentiality obligations of the NDA are reciprocal. Fleet and the other party to the agreement should be bound to the same standards of confidentiality toward the handling of each other's confidential information. +- Fleet does not agree to _"do not compete"_ or _"do not solicit clauses"_. An NDA should not contain provisions beyond the scope of an NDA. The two most commonly encountered examples of this are the "do not compete" and "do not solicit" clauses. We want to be free to hire the best people and make the best products, so when reading through an NDA it is important to keep an eye out for language that prohibits Fleet from hiring or soliciting current or former employees of other companies or that prohibit Fleet from independently developing products that compete with another company's products. Using the `cmd + f` function to search for "solici", "compet" and "hir" and reading through the results is a helpful method to quickly scan for these clauses. +- Look for any language that discusses a transfer of property rights. Rarely, you may find a clause snuck into an agreement that discusses the transfer of intellectual property rights. _We want to avoid any situation where Fleet transfers its intellectual property to another party as part of an NDA_. +- Should you find any clauses in steps 2 or 3 that are beyond the scope of protecting both party's confidential information in a customer NDA or an altered version of Fleet's NDA, reject this language and communicate that Fleet cannot agree to those terms. +- Any concerns or uncertainty over _any_ provisions in an NDA should be brought to Nathanael Holliday in Digital Experience, who will consult legal counsel if necessary to resolve any concerns. + +### Review a vendor agreement +When reviewing contracts from a vendor, Fleet is concerned about the following: +- If there are confidentiality provisions in the agreement in place of a stand-alone NDA, verify the confidentiality provisions are appropriate and protect Fleet when sensitive data is involved that isn't otherwise available to the public. +- We want to make sure there are no _do not solicit_ or _do not compete_ clauses in the contract. To aid in this search, we double check by using the cmd + f function and searching for "solici", "compet" and "hir" and then looking through the results to be sure that nothing prohibits Fleet from independently developing competing products or from hiring personnel with ties to the vendor. +- We want to make sure that contracts can be terminated relatively easily and be aware of what the process is for terminating them, avoiding commitments over 12 months in length. +- We want to make sure the payment terms work for us (i.e. being able to pay via wire transfer, credit card or bill.com) and that the price in any contract or order form is what we have agreed to. While almost never malicious, mistakes often occur in the steps between agreeing on a price, negotiating a contract, and receiving an invoice. We want to be sure at every step that the dollar amount and service provided is consistent with what has been negotiated and agreed upon. +- Remember, once we have signed the agreement - we're stuck with it. If any clause in the agreement appears strange or gives you pause or concern, it is better to seek clarification than to commit to something that might be detrimental to Fleet. Contracts are fairly standardized, and you'll quickly learn what is normal and what feels out of place. Unusual clauses or wording that seems out of the ordinary should get a second set of eyes just to be sure, do not hesitate to reach out to Nathanael Holliday with questions, who will reach out to legal counsel as necessary. + +### Review an order form +- We should always check order forms for additional terms that go beyond the scope of the order form (caps on price increases, for example). +- Be sure the order form includes contact information + billing address and information so that Fleet knows how and who to invoice for payment. +- Verify that the payment terms are correct and matches what's in the agreement. This is a frequent common mistake as companies usually have default payment terms and overlook changing them to match atypical payment terms. +- Make sure the effective term of the order matches what was agreed upon (usually a one year term) and that the order form includes the correct number of hosts and whether or not it should contain professional services (usually, it does not). +- Check that the amount on the order form reflects what Fleet agreed to, as this is the amount that the customer will expect to be invoiced for. +- Lastly, double check one more time to make sure there are no sneaky, unusual terms snuck in at the bottom of an order form or stashed away in fine print. Common things that are included in order forms and not always communicated to Fleet are caps on price increases upon renewal, new SLAs, or a product roadmap or milestones we may not have agreed upon. Any clauses on an order form that appear beyond the scope of simply elaborating on the services being provided, the purchase cost, the contract that the purchase is being made under, how Fleet will bill and how the customer will pay deserves a careful look. Reach out to Nathanael Holliday in Digital Experience with concerns. + +### Review a non-standard subscription agreement +We want to use our standard terms whenever possible with our customers, but it is common that customers want to use their own agreement or redline (modify) Fleet's terms. +When reviewing subscription agreements on customer paper or when a customer has made changes to Fleet's terms, we review it using [these guidelines](https://docs.google.com/document/d/1aGgN5It1i3fdsBF37vWSbvukO_gQhy5vCp4fINg191Q/edit?usp=sharing). + +### QA a change to fleetdm.com Each PR to the website is manually checked for quality and tested before going live on fleetdm.com. To test any change to fleetdm.com 1. Write clear step-by-step instructions to confirm that the change to the fleetdm.com functions as expected and doesn't break any possible automation. These steps should be simple and clear enough for anybody to follow. @@ -41,7 +250,7 @@ Each PR to the website is manually checked for quality and tested before going l ### Update the host count of a premium subscription -When a self-service license dispenser customer reaches out to upgrade a license via the contact form, a member of the [Demand department](https://fleetdm.com/handbook/demand) will create a confidential issue detailing the request and add it to the new requests column of Ditigal Experience kanban board. A member of this team will then log into Stripe using the shared login, and upgrade the customer's subscription. +When a self-service license dispenser customer reaches out to upgrade a license via the contact form, a member of the [Demand department](https://fleetdm.com/handbook/demand) will create a confidential issue detailing the request and add it to the new requests column of [Digital Experience kanban board](https://github.com/fleetdm/confidential/issues#workspaces/g-digital-experience-6451748b4eb15200131d4bab/board). A member of this team will then log into Stripe using the shared login, and upgrade the customer's subscription. To update the host count on a user's subscription: @@ -88,7 +297,7 @@ Once you have the above follow these steps: ### Check production dependencies of fleetdm.com -Every week, we run `npm audit --only=prod` to check for vulnerabilities on the production dependencies of fleetdm.com. Once we have a solution to configure GitHub's Dependabot to ignore devDependencies, this manual process can be replaced with Dependabot. +Every week, we run `npm audit --only=prod` to check for vulnerabilities on the production dependencies of fleetdm.com. Once we have a solution to configure GitHub's Dependabot to ignore devDependencies, this [manual process](https://www.loom.com/share/153613cc1c5347478d3a9545e438cc97?sid=5102dafc-7e27-43cb-8c62-70c8789e5559) can be replaced with Dependabot. ### Respond to a 5xx error on fleetdm.com @@ -104,7 +313,7 @@ Production systems can fail for various reasons, and it can be frustrating to us ### Check browser compatibility for fleetdm.com -A browser compatibility check of [fleetdm.com](https://fleetdm.com/) should be carried out monthly to verify that the website looks and functions as expected across all [supported browsers](https://fleetdm.com/docs/using-fleet/supported-browsers). +A [browser compatibility check](https://www.loom.com/share/4b1945ccffa14b7daca8ab9546b8fbb9?sid=eaa4d27a-236b-426d-a7cb-9c3bdb2c8cdc) of [fleetdm.com](https://fleetdm.com/) should be carried out monthly to verify that the website looks and functions as expected across all [supported browsers](https://fleetdm.com/docs/using-fleet/supported-browsers). - We use [BrowserStack](https://www.browserstack.com/users/sign_in) (logins can be found in [1Password](https://start.1password.com/open/i?a=N3F7LHAKQ5G3JPFPX234EC4ZDQ&v=3ycqkai6naxhqsylmsos6vairu&i=nwnxrrbpcwkuzaazh3rywzoh6e&h=fleetdevicemanagement.1password.com)) for our cross-browser checks. - Check for issues against the latest version of Google Chrome (macOS). We use this as our baseline for quality assurance. @@ -123,7 +332,7 @@ In Figma: - Avoid using SVGs or icon fonts. 3. Click the __Export__ button. - + ### Restart Algolia manually @@ -226,7 +435,7 @@ Certain new team members, especially in go-to-market (GTM) roles, will need paid ### Downgrade an unused license seat -- On the first Wednesday of every quarter, the CEO, head of BizOps and Head of Digital experience will meet for 30 minutes to audit license seats in Figma, Slack, GitHub, Salesforce and other tools. +- On the first Wednesday of every quarter, the CEO and Head of Digital experience will meet for 30 minutes to audit license seats in Figma, Slack, GitHub, Salesforce and other tools. - During this meeting, as many seats will be downgraded as possible. When doubt exists, downgrade. - Afterward, post in #random letting folks know that the quarterly tool reconciliation and seat clearing is complete, and that any members who lost access to anything they still need can submit a ZenHub issue to Digital Experience to have their access restored. - The goal is to build deep, integrated knowledge of tool usage across Fleet and cut costs whenever possible. It will also force conversations on redundancies and decisions that aren't helping the business that otherwise might not be looked at a second time. @@ -243,10 +452,19 @@ Here are the steps we take to grant appropriate Salesforce licenses to a new hir - Once the basic license has been added, you can create a new user using the new team member's `@fleetdm.com` email and assign a license to it. - To also assign a user an "Inbox license", go to the ["Setup" page](https://fleetdm.lightning.force.com/lightning/setup/SetupOneHome/home) and select "User > Permission sets". Find the [inbox permission set](https://fleetdm.lightning.force.com/lightning/setup/PermSets/page?address=%2F005%3Fid%3D0PS4x000002uUn2%26isUserEntityOverride%3D1%26SetupNode%3DPermSets%26sfdcIFrameOrigin%3Dhttps%253A%252F%252Ffleetdm.lightning.force.com%26clc%3D1) and assign it to the new team member. +### Change the "Integrations admin" Salesforce account password + +Salesforce requires that the password to the "Integrations admin" account is changed every 90 days. When this happens, the Salesforce integrations on the Fleet website/Hydroplane will fail with an `INVALID_LOGIN` error. To prevent this from happening, a member of the Digital expererience team will: + +1. Log into the "Integrations admin" account in Salesforce. +2. Change the password and save it in the shared 1Password vault. +3. Request a new security token for the "Integrations admin" account (This will be sent to the email address associated with the account). +4. Update the `sails_config__custom_salesforceIntegrationPasskey` config variable in Heroku to be `[password][security token]` (For both the Fleet website and Hydroplane). + ### Schedule press release -Fleet will occasionally release information to the press regarding upcoming initiatives before updating the functionality of the core product. This process sUse the following steps to schedule a press release: +Fleet will occasionally release information to the press regarding upcoming initiatives before updating the functionality of the core product. Use the following steps to schedule a press release: 1. Add context for the next press release to the [e-group agenda](https://docs.google.com/document/d/13fjq3T0bZGOUah9cqHVxngckv0EB2R24A3gfl5cH7eo/edit) as a "DISCUSS:" to be reviewed by Fleet's executive team for alignment and finalization of date. 2. Once a release date is set, at-mention our public relations firm in the [#help-public-relations-firm--mindshare-pr--brand-marketing](https://fleetdm.slack.com/archives/C04PC9H34LF) and schedule a 30m call for our CEO and to communicate the press release. @@ -281,12 +499,22 @@ Follow these steps to archive any document: ### Schedule CEO interview -From time to time, you will need to schedule an interview between a candidate and the CEO: -1. [Make a copy of the "¶¶ CEO interview template"](https://docs.google.com/document/d/1yARlH6iZY-cP9cQbmL3z6TbMy-Ii7lO64RbuolpWQzI/copy) (private Google doc) -2. Change file name and heading of doc to `¶¶ CANDIDATE_NAME (CANDIDATE_TITLE) <> Mike McNeil, CEO final interview (YYYY-MM-DD)` +Use the following steps to schedule an interview between a candidate and the CEO: +1. Once you receive a [CEO interview request](https://fleetdm.com/handbook/company/leadership#hiring-a-new-team-member), apply the "eyes" (👀) emoji to the Slack post to acknowledge you've seen the request. +2. Reach out to the candidate via email to find a time when the CEO and candidate are both available. + > This entire process takes an hour for the CEO: a 30-minute interview followed by a 30-minute "¶¶ Postgame" Be sure to offer times that accommodate this. +3. [Make a copy of the "¶¶ CEO interview template"](https://docs.google.com/document/d/1yARlH6iZY-cP9cQbmL3z6TbMy-Ii7lO64RbuolpWQzI/copy) (private Google doc) and move it to the "[🕵️ ¶±¶ Reference checks & hiring data](https://drive.google.com/drive/folders/1VgKT6_VrQ9zYMnDOwJGE1mT1WrrMFqJw?usp=drive_link)" folder in Google Drive. +4. Prep the CEO interview doc: + - Change file name and heading of doc to `¶¶ CANDIDATE_NAME (CANDIDATE_TITLE) <> Mike McNeil, CEO final interview (YYYY-MM-DD)`. - Add candidate's personal email in the "👥" (attendees) section at the top of the doc. - Add candidate's [LinkedIn url](https://www.linkedin.com/search/results/all/?keywords=people) on the first bullet for Mike. -3. Set the Google Calendar description of the calendar event to: `Agenda: URL_FOR_NEW_COPY_OF_FINAL_INTERVIEW_DOC` + - Share the CEO interview doc with the hiring manager as a "Commenter". +5. Link the CEO interview doc at the top of the "feedback" doc shared in the CEO interview request +6. Create a Google Calendar event at a time when the CEO and the candidate are both available. + - Create a Google Calendar event matching the title of the interview doc. + - Add the interview doc to the calendar event description as the agenda (i.e. `Agenda: INTERVIEW_DOC_FULL_URL`) and save the calendar event. +7. Schedule a 30-minute "¶¶ Postgame" working session for the CEO to evaluate the candidate and give his recommendation. +8. In the hiring channel for the position, apply the "green-check-mark" (✅) emoji to the CEO interview request to confirm the request has been processed. ### Program the CEO to do something @@ -308,11 +536,11 @@ Agenda: When an agreement is routed to the CEO for signature, the [Apprentice](https://fleetdm.com/handbook/digital-experience#team) is responsible for obtaining a signature from the CEO using the following steps: 1. Drag the email to the ["🔏 SAM: Signature wanted"](https://mail.google.com/mail/u/0/#label/SAM%3A+Signature+wanted) label making sure to mark the email as unread. -2. A Business Operations Engineer will at-mention the Apprentice in a legal review issue, letting them know the contract is good to go. After that, move the email to the "[✍️ MIKE: Ready to sign](https://mail.google.com/mail/u/0/#label/%E2%9C%8D%EF%B8%8F+MIKE%3A+Ready+to+sign)" label +2. The [Contracts and Compliance Engineer](https://fleetdm.com/handbook/digital-experience#team) will at-mention the Apprentice in a legal review issue, letting them know the contract is good to go. After that, move the email to the "[✍️ MIKE: Ready to sign](https://mail.google.com/mail/u/0/#label/%E2%9C%8D%EF%B8%8F+MIKE%3A+Ready+to+sign)" label > If the agreement closes a deal, inform the CEO (via Slack DM) that a subscription agreement is ready for his review/signature. The SLA for CEO review and signature is 48hrs. -3. Comment in the issue once the CEO has signed the agreement and assign the issue to [Nathan Holiday](https://fleetdm.com/handbook/business-operations#team). +3. Comment in the issue once the CEO has signed the agreement and assign the issue to [Nathan Holiday](https://fleetdm.com/handbook/digital-experience#team). ### Prepare for CEO office minutes @@ -355,6 +583,14 @@ Time management for the CEO is essential. The Apprentice processes the CEO's ca 6. Edit the calendar event description, changing “Notes” to “Agenda” when you're finished preparing the document to signify that this meeting has been prepped. +### Confirm CEO shadow dates + +After the team member notifies the Head of Digital Experience (via Slack), the Head of DigExp will bring the dates to the next roundup as a "DISCUSS: CEO shadow dates". Use the following steps to confirm CEO shadow dates: +1. Create an "All day", "Free" event on the CEO's calendar that matches the CEO shadow dates and name the calendar event "CEO shadow - [NAME] (Job title)". +3. Confirm the "shadowability" for external and nonrecurring internal meetings with the CEO during the next daily 🐈‍⬛🌪️ Roundup. +4. Go through the calendar and make sure all private meetings (e.g. 1:1's, E-Group, and quarterly board meetings) have "[no shadows]" in the event title. + + ### Process the CEO's inbox - The Apprentice is [responsible](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) for [processing all email traffic](https://docs.google.com/document/d/1gH3IRRgptrqSYzBFy-77g98JROTL8wqrazJIMkp-Gb4/edit#heading=h.i7mkhr6m123r) prior to CEO review to reduce the scope of Mike's inbox to only include necessary and actionable communication. @@ -366,9 +602,9 @@ Time management for the CEO is essential. The Apprentice processes the CEO's ca ### Document performance feedback -Every Friday at 5PM a [Business Operations team member](https://fleetdm.com/handbook/business-operations#team) will look for missing data in the [KPIs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). -1. If KPIs are not reported on time, the BizOps Engineer will notify the Apprentice to the CEO and the DRI. -2. The Apprentice will update the "performance management" section of the appropriate individual's 1:1 doc so that the CEO can address during the next 1:1 meeting with the DRI. +Every Friday at 5PM a [Digital Experience team member](https://fleetdm.com/handbook/digital-experience#team) will look for missing data in the [KPIs spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). +1. If KPIs are not reported on time, notify the Head of Digital Experience and the DRI. +2. The Head of Digital Experience will update the "performance management" section of the appropriate individual's 1:1 doc so that the CEO can address during the next 1:1 meeting with the DRI. ### Send the weekly update @@ -383,7 +619,8 @@ We like to be open about milestones and announcements. Every Friday, e-group mem To send the weekly update follow these steps: 1. Navigate to the current weeks row in the [KPIs Google Sheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). -2. Copy the entire formula in this weeks "Weekly update" update cell and paste without formating (CMD+⇧+V) back into the same cell. The formula will now look like this: +2. Check the KPI sheet at 5pm US central time to ensure all departments have updated their KPIs on time. If any departments are delinquent, notify the department head and [document performance feedback](https://fleetdm.com/handbook/digital-experience#document-performance-feedback). +3. Copy the entire formula in this weeks "Weekly update" update cell and paste without formating (CMD+⇧+V) back into the same cell. The formula will now look like this: image @@ -517,6 +754,12 @@ It's not enough to just "delete" a recording of a meeting in Gong. Instead, use - Search for the title of the meeting Google Drive and delete the auto-generated Google Doc containing the transcript. - Always check back to ensure the recording **and** transcript were both deleted. +### Update a company brand front + +Fleet has several brand fronts that need to be updated from time to time. Check each [brand front](https://docs.google.com/spreadsheets/d/1c15vwMZytpCLHUdGvXxi0d6WGgPcQU1UBMniC1F9oKk/edit?gid=0#gid=0) for consistency and update as needed with the following: +- The current pitch, found in the blurbs section of the [🎐 Why Fleet?](https://docs.google.com/document/d/1E0VU4AcB6UTVRd4JKD45Saxh9Gz-mkO3LnGSTBDLEZo/edit#heading=h.uovxedjegxdc) doc. +- The current [brand imagery](https://www.figma.com/design/1J2yxqH8Q7u8V7YTtA1iej/Social-media-(logos%2C-covers%2C-banners)?node-id=3962-65895). Check this [Loom video](https://www.loom.com/share/4432646cc9614046aaa4a74da1c0adb5?sid=2f84779f-f0bd-4055-be69-282c5a16f5c5) for more info. + ## Rituals diff --git a/handbook/business-operations/Application-security.md b/handbook/digital-experience/application-security.md similarity index 77% rename from handbook/business-operations/Application-security.md rename to handbook/digital-experience/application-security.md index e914e99f0f..3c17410299 100644 --- a/handbook/business-operations/Application-security.md +++ b/handbook/digital-experience/application-security.md @@ -1,13 +1,13 @@ # Application security -- [Describe your secure coding practices (SDLC)](https://fleetdm.com/handbook/business-operations/application-security#describe-your-secure-coding-practices-including-code-reviews-use-of-static-dynamic-security-testing-tools-3-rd-party-scans-reviews) -- [SQL injection](https://fleetdm.com/handbook/business-operations/application-security#sql-injection) -- [Broken authentication](https://fleetdm.com/handbook/business-operations/application-security#broken-authentication-authentication-session-management-flaws-that-compromise-passwords-keys-session-tokens-etc) - - [Passwords](https://fleetdm.com/handbook/business-operations/application-security#passwords) - - [Authentication tokens](https://fleetdm.com/handbook/business-operations/application-security#authentication-tokens) -- [Sensitive data exposure](https://fleetdm.com/handbook/business-operations/application-security#sensitive-data-exposure-encryption-in-transit-at-rest-improperly-implemented-apis) -- [Cross-site scripting](https://fleetdm.com/handbook/business-operations/application-security#cross-site-scripting-ensure-an-attacker-cant-execute-scripts-in-the-users-browser) -- [Components with known vulnerabilities](https://fleetdm.com/handbook/business-operations/application-security#components-with-known-vulnerabilities-prevent-the-use-of-libraries-frameworks-other-software-with-existing-vulnerabilities) +- [Describe your secure coding practices (SDLC)](https://fleetdm.com/handbook/digital-experience/application-security#describe-your-secure-coding-practices-including-code-reviews-use-of-static-dynamic-security-testing-tools-3-rd-party-scans-reviews) +- [SQL injection](https://fleetdm.com/handbook/digital-experience/application-security#sql-injection) +- [Broken authentication](https://fleetdm.com/handbook/digital-experience/application-security#broken-authentication-authentication-session-management-flaws-that-compromise-passwords-keys-session-tokens-etc) + - [Passwords](https://fleetdm.com/handbook/digital-experience/application-security#passwords) + - [Authentication tokens](https://fleetdm.com/handbook/digital-experience/application-security#authentication-tokens) +- [Sensitive data exposure](https://fleetdm.com/handbook/digital-experience/application-security#sensitive-data-exposure-encryption-in-transit-at-rest-improperly-implemented-apis) +- [Cross-site scripting](https://fleetdm.com/handbook/digital-experience/application-security#cross-site-scripting-ensure-an-attacker-cant-execute-scripts-in-the-users-browser) +- [Components with known vulnerabilities](https://fleetdm.com/handbook/digital-experience/application-security#components-with-known-vulnerabilities-prevent-the-use-of-libraries-frameworks-other-software-with-existing-vulnerabilities) The Fleet community follows best practices when coding. Here are some of the ways we mitigate against the OWASP top 10 issues: diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index bc2b4db7ca..c36e75db31 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -1,6 +1,24 @@ # https://github.com/fleetdm/fleet/pull/13084 - - +- + task: "Complete Digital Experience KPIs" + startedOn: "2024-08-30" + frequency: "Weekly" + description: "Complete Digital Experience KPIs for this week" + moreInfoUrl: "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0&range=DB1" + dri: "SFriendLee" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "fleet" +- + task: "Prep 1:1s for OKR planning" + startedOn: "2024-09-09" + frequency: "Monthly" + description: "Add ”DISCUSS: Mike: Expectations of OKR planning“ to each e-group member's 1:1 document" + moreInfoUrl: "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit" + dri: "SFriendLee" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: fleet - task: "Check browser compatibility for fleetdm.com" startedOn: "2024-03-06" @@ -18,6 +36,13 @@ description: "Run through the entire website in `?utm_content=clear` mode and build a fresh outline of the headings to make sure they all still make sense." moreInfoUrl: "" dri: "mike-j-thomas" +- + task: "Check brand fronts are up to date" + startedOn: "2024-08-01" + frequency: "Quarterly" + description: "Check all brand fronts for consistancy and update as needed with the current product pitch and graphics." + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#update-a-company-brand-front" + dri: "mike-j-thomas" - task: "Check production dependencies of fleetdm.com" startedOn: "2023-11-10" @@ -49,7 +74,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "sampfluger88" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues @@ -98,8 +123,8 @@ repo: "confidential" - task: "Process and backup Sid agenda" - startedOn: "2023-09-15" - frequency: "Weekly" + startedOn: "2023-09-25" + frequency: "Monthly" description: "Process and backup Sid agenda" moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#process-and-backup-e-group-agenda" dri: "SFriendLee" @@ -150,7 +175,7 @@ startedOn: "2024-03-31" frequency: "Quarterly" description: "Downgrade unused or questionable license seats on the first Wednesday of every quarter" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#downgrade-an-unused-license-seat" + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#downgrade-an-unused-license-seat" dri: "sampfluger88" - task: "Communicate Fleet's potential energy to stakeholders" @@ -162,15 +187,34 @@ autoIssue: labels: [ "#g-digital-experience" ] repo: "confidential" - - - - - - - - - - - - +- + task: "Vanta check" # TODO tie this to a responsibility + startedOn: "2024-04-01" + frequency: "Monthly" + description: "Look for any new actions in Vanta due in the upcoming months and create issues to ensure they're done on time." + moreInfoUrl: + dri: "sampfluger88" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "confidential" +- + task: "Recognize and benchmark workiversaries" + startedOn: "2024-07-15" + frequency: "Bimonthly" + description: "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked" + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#recognize-employee-workiversaries" + dri: "sampfluger88" +- + task: "Quarterly grants" + startedOn: "2024-02-01" + frequency: "Quarterly" + description: "Create the equity grants GitHub issue and walk through the steps." + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#grant-equity" + dri: "hollidayn" +- + task: "Change password of \"Integrations admin\" Salesforce account" + startedOn: "2024-09-10" + frequency: "Quarterly" + description: "Log into the \"Integrations admin\" account in Salesforce and change the password to prevent a password change being required by Salesforce." + moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#change-the-integrations-admin-salesforce-account-password" + dri: "eashaw" diff --git a/handbook/business-operations/security-audits.md b/handbook/digital-experience/security-audits.md similarity index 94% rename from handbook/business-operations/security-audits.md rename to handbook/digital-experience/security-audits.md index 0df67d30d3..81a3af83b6 100644 --- a/handbook/business-operations/security-audits.md +++ b/handbook/digital-experience/security-audits.md @@ -1,6 +1,15 @@ # Security audits This page contains explanations of the latest external security audits performed on Fleet software. +## June 2024 penetration testing of Fleet 4.50.1 +In June 2024, [Latacora](https://www.latacora.com/) performed an application penetration assessment of the application from Fleet. + +An application penetration test captures a point-in-time assessment of vulnerabilities, misconfigurations, and gaps in applications that could allow an attacker to compromise the security, availability, processing integrity, confidentiality, and privacy (SAPCP) of sensitive data and application resources. An application penetration test simulates the capabilities of a real adversary, but accelerates testing by using information provided by the target company. + +Latacora identified a few medium and low severity risks, and Fleet is prioritizing and responding to those within SLAs. Once all action has been taken, a summary will be provided. + +You can find the full report here: [2024-06-14-fleet-penetration-test.pdf](https://github.com/fleetdm/fleet/raw/main/docs/files/2024-06-14-fleet-penetration-test.pdf). + ## June 2023 penetration testing of Fleet 4.32 In June 2023, [Latacora](https://www.latacora.com/) performed an application penetration assessment of the application from Fleet. diff --git a/handbook/business-operations/security-policies.md b/handbook/digital-experience/security-policies.md similarity index 99% rename from handbook/business-operations/security-policies.md rename to handbook/digital-experience/security-policies.md index 42a911c991..842e67a44d 100644 --- a/handbook/business-operations/security-policies.md +++ b/handbook/digital-experience/security-policies.md @@ -102,7 +102,7 @@ Fleet policy requires that: - Use of shared credentials/secrets must be minimized. -- If required by business operations, secrets/credentials must be shared securely and stored in encrypted vaults that meet the Fleet data encryption standards. +- If required by Digital Experience, secrets/credentials must be shared securely and stored in encrypted vaults that meet the Fleet data encryption standards. ### Privileged access management @@ -158,7 +158,7 @@ For technical incidents: For business/operational incidents: - CEO (Mike McNeil) -- Head of Business Operations (Joanne Stableford) +- Head of Digital Experience (Sam Pfluger) ### Response Teams and Responsibilities @@ -612,7 +612,7 @@ CTO | Oversight over information sec | System owners | Manage the confidentiality, integrity, and availability of the information systems for which they are responsible in compliance with Fleet policies on information security and privacy.
    Approve of technical access and change requests for non-standard access | | Employees, contractors, temporary workers, etc. | Acting at all times in a manner that does not place at risk the security of themselves, colleagues, and the information and resources they have use of
    Helping to identify areas where risk management practices should be adopted
    Adhering to company policies and standards of conduct Reporting incidents and observed anomalies or weaknesses | | Head of People Operations | Ensuring employees and contractors are qualified and competent for their roles
    Ensuring appropriate testing and background checks are completed
    Ensuring that employees and relevant contractors are presented with company policies
    Ensuring that employee performance and adherence to values is evaluated
    Ensuring that employees receive appropriate security training | -| Head of Business Operations | Responsible for oversight over third-party risk management process; responsible for review of vendor service contracts | +| Head of Digital Experience | Responsible for oversight over third-party risk management process; responsible for review of vendor service contracts | ## Network and system hardening standards Fleet leverages industry best practices for network hardening, which involves implementing a layered defense strategy called defense in depth. This approach ensures multiple security controls protect data and systems from internal and external threats. @@ -790,4 +790,4 @@ Fleet makes every effort to assure all third-party organizations are compliant a > Fleet is committed to ethical business practices and compliance with the law. All Fleeties are required to comply with the "Foreign Corrup Practices Act" and anti-bribery laws and regulations in applicable jurisdictions including, but not limited to, the "UK Bribery Act 2010", "European Commission on Anti-Corruption" and others. The policies set forth in [this document](https://docs.google.com/document/d/16iHhLhAV0GS2mBrDKIBaIRe_pmXJrA1y7-gTWNxSR6c/edit?usp=sharing) go over Fleet's anti-corruption policy in detail. - + \ No newline at end of file diff --git a/handbook/business-operations/security.md b/handbook/digital-experience/security.md similarity index 96% rename from handbook/business-operations/security.md rename to handbook/digital-experience/security.md index ab0b90337b..46bbbf31ed 100644 --- a/handbook/business-operations/security.md +++ b/handbook/digital-experience/security.md @@ -27,7 +27,7 @@ As an all-remote company, we do not have the luxury of seeing each other or bein | Participant | Role | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Requester | Requests recovery for their own account | -| Recoverer | Person with access to perform the recovery who monitors `#g-business-operations` | +| Recoverer | Person with access to perform the recovery who monitors `#g-digital-experience` | | Identifier | Person that visually identifies the requester in a video call. The identifier can be the recoverer or a person the recoverer can recognize visually | @@ -35,10 +35,10 @@ As an all-remote company, we do not have the luxury of seeing each other or bein 1. If the requester still has access to GitHub and/or Slack, they [ask for - help](https://fleetdm.com/handbook/business-operations#intake). For non-urgent requests, please - prefer filing an issue with the business operations team. If they do not have access, + help](https://fleetdm.com/handbook/digital-experience#contact-us). For non-urgent requests, please + prefer filing an issue with the Digital Experience team. If they do not have access, they can contact their manager or a teammate over the phone via voice or texting, and they will - [ask for help](https://fleetdm.com/handbook/business-operations#intake) on behalf of the + [ask for help](https://fleetdm.com/handbook/digital-experience#contact-us) on behalf of the requester. 2. The recoverer identifies the requester through a live video call. * If the recoverer does not know the requester well enough to positively identify them visually, the @@ -311,32 +311,6 @@ We do not apply ultra restrictive Data Loss Prevention style policies to our dev We use osquery and Fleet to monitor our own devices. This is used for vulnerability detection, security posture tracking, and incident response when necessary. -#### Deploy Nudge -Keeping operating systems up to date is important to fix known vulnerabilities. This is why we enable automatic updates on macOS, but as that system is neither aggressive or reliable enough, we also use Nudge to push individuals to update their systems before a deadline. - -##### Deploying Nudge -Two packages from the Nudge [releases](https://github.com/macadmins/nudge/releases) must be deployed via MDM. - -1. Nudge itself. This is the Nudge executables that display prompts to update the system. -2. Nudge LaunchAgent. This is the package that contains the automated tasks that make Nudge check if the system is up to date, and if not, to show the prompt. - -If only Nudge is deployed, nothing will happen on the system, as it will never launch unless triggered manually. The main reason to only install Nudge would be to run it manually for testing purposes, or if some other tool was used to schedule running it. - -At Fleet, we use the standard LaunchAgent. - -We do not bundle any configuration with the Nudge packages themselves. - -##### Nudge configuration -Nudge supports multiple configuration modes, but the one we use is via a [profile](https://github.com/fleetdm/confidential/blob/main/mdm_profiles/nudge_configuration.mobileconfig). (Note: our MDM profiles are not public simply because a few of them contain secrets, such as Chrome organization identification strings. Our Nudge profile is extremely similar to the [sample one](https://github.com/macadmins/nudge/blob/main/Example%20Assets/com.github.macadmins.Nudge.mobileconfig)). - -By joining a laptop to our MDM and deploying profiles, Nudge will get configured. - -When a new update is released, the following fields must be updated: - -* `aboutUpdateURLs` in all languages, pointing to the Apple page with information about vulnerabilities fixed in each update. If an update had no vulnerabilities fixed, we'd typically not enforce it via Nudge, but this is extremely rare. -* `requiredMinimumOSVersion` must be set to the new version (ex: `13.1`). -* `requiredInstallationDate` must be set to a date in the future, based on the criticality of the vulnerabilities fixed by the update. - ### Chrome configuration We configure Chrome on company-owned devices with a basic policy. @@ -896,12 +870,12 @@ questions and more on [https://fleetdm.com/trust](https://fleetdm.com/trust) ## Securtiy audits -Read about Fleet's security audits on [this page](https://fleetdm.com/handbook/business-operations/security-audits). +Read about Fleet's security audits on [this page](https://fleetdm.com/handbook/digital-experience/security-audits). ## Application security -Read about Fleet's application security practices on the [application security page](https://fleetdm.com/handbook/business-operations/application-security). +Read about Fleet's application security practices on the [application security page](https://fleetdm.com/handbook/digital-experience/application-security). diff --git a/handbook/business-operations/vendor-questionnaires.md b/handbook/digital-experience/vendor-questionnaires.md similarity index 95% rename from handbook/business-operations/vendor-questionnaires.md rename to handbook/digital-experience/vendor-questionnaires.md index 8af1763870..ee3bf32cd2 100644 --- a/handbook/business-operations/vendor-questionnaires.md +++ b/handbook/digital-experience/vendor-questionnaires.md @@ -17,7 +17,7 @@ Please also see [Application security](https://fleetdm.com/docs/using-fleet/appl ## Data security -Please also see ["Data security"](https://fleetdm.com/handbook/business-operations/security-policies#data-management-policy) +Please also see ["Data security"](https://fleetdm.com/handbook/digital-experience/security-policies#data-management-policy) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Should the need arise during an active relationship, how can our Data be removed from the Fleet's environment? | Customer data is primarily stored in RDS, S3, and Cloudwatch logs. Deleting these resources will remove the vast majority of customer data. Fleet can take further steps to remove data on demand, including deleting individual records in monitoring systems if requested. | @@ -35,7 +35,7 @@ Please also see ["Data security"](https://fleetdm.com/handbook/business-operatio | Can Fleet customers access service logs? | Logs will not be accessible by default, but can be provided upon request. | ## Encryption and key management -Please also see [Encryption and key management](https://fleetdm.com/handbook/business-operations/security-policies#encryption-policy) +Please also see [Encryption and key management](https://fleetdm.com/handbook/digital-experience/security-policies#encryption-policy) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Does Fleet have a cryptographic key management process (generation, exchange, storage, safeguards, use, vetting, and replacement), that is documented and currently implemented, for all system components? (e.g. database, system, web, etc.) | All data is encrypted at rest using methods appropriate for the system (ie KMS for AWS based resources). Data going over the internet is encrypted using TLS or other appropiate transport security. | @@ -48,10 +48,10 @@ Please also see [Encryption and key management](https://fleetdm.com/handbook/bus | Does Fleet have documented information security baselines for every component of the infrastructure (e.g., hypervisors, operating systems, routers, DNS servers, etc.)? | Fleet follows best practices for the given system. For instance, with AWS we utilize AWS best practices for security including GuardDuty, CloudTrail, etc. | ## Business continuity -Please also see [Business continuity](https://fleetdm.com/handbook/business-operations/security-policies#business-continuity-plan) +Please also see [Business continuity](https://fleetdm.com/handbook/digital-experience/security-policies#business-continuity-plan) | Question | Answer | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| Please provide your application/solution disaster recovery RTO/RPO | RTO and RPO intervals differ depending on the service that is impacted. Please refer to https://fleetdm.com/handbook/business-operations/security-policies#business-continuity-and-disaster-recovery-policy | +| Please provide your application/solution disaster recovery RTO/RPO | RTO and RPO intervals differ depending on the service that is impacted. Please refer to https://fleetdm.com/handbook/digital-experience/security-policies#business-continuity-and-disaster-recovery-policy | ## Network security | Question | Answer | diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index d6f18bde2c..388a5c8724 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -111,11 +111,10 @@ If there is partially merged feature work when the release candidate is created, Before kicking off release QA, confirm that we are using the latest versions of dependencies we want to keep up-to-date with each release. Currently, those dependencies are: 1. **Go**: Latest minor release -- Check the [version included in Fleet](https://github.com/fleetdm/fleet/settings/variables/actions). +- Check the [Go version specified in Fleet's go.mod file](https://github.com/fleetdm/fleet/blob/main/go.mod) (`go 1.XX.YY`). - Check the [latest minor version of Go](https://go.dev/dl/). For example, if we are using `go1.19.8`, and there is a new minor version `go1.19.9`, we will upgrade. - If the latest minor version is greater than the version included in Fleet, [file a bug](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. Add the `~release blocker` label. We must upgrade to the latest minor version before publishing the next release. - If the latest major version is greater than the version included in Fleet, [create a story](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story%2C%3Aproduct&projects=&template=story.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. This will be considered for an upcoming sprint. The release can proceed without upgrading the major version. -- Note that major version upgrades also require an [update to go.mod](https://github.com/fleetdm/fleet/blob/7b3134498873a31ba748ca27fabb0059cef70db9/go.mod#L3). > In Go versioning, the number after the first dot is the "major" version, while the number after the second dot is the "minor" version. For example, in Go 1.19.9, "19" is the major version and "9" is the minor version. Major version upgrades are assessed separately by engineering. @@ -131,7 +130,7 @@ Our goal is to keep these dependencies up-to-date with each release of Fleet. If 3. **osquery**: Latest release - Check the [latest version of osquery](https://github.com/osquery/osquery/releases). -- Check the [version included in Fleet](https://github.com/fleetdm/fleet/blob/ceb4e4602ba9a90ebf0e33e1eddef770c9a8d8b5/.github/workflows/generate-osqueryd-targets.yml#L27). +- Check the [version included in Fleet](https://github.com/fleetdm/fleet/blob/main/.github/workflows/generate-osqueryd-targets.yml#L27). - If the latest release of osquery is greater than the version included in Fleet, [file a bug](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the [current on-call engineer](https://fleetdm.com/handbook/engineering#how-to-reach-the-oncall-engineer). - Do not add the `~release blocker` label. - Update the bug description to note that changes to [osquery command-line flags](https://osquery.readthedocs.io/en/stable/installation/cli-flags/) require updates to Fleet's flag validation and related documentation [as shown in this pull request](https://github.com/fleetdm/fleet/pull/16239/files). @@ -464,7 +463,7 @@ When this occurs, we will begin receiving the following error message when attem 2. Log in using the credentials stored in 1Password under "Apple developer account". -3. Contact the Head of Business Operations to determine which phone number to use for 2FA. +3. Contact the Head of Digital Experience to determine which phone number to use for 2FA. 4. Complete the 2FA process to log in. @@ -492,7 +491,7 @@ The certificate signing request (CSR) certificate expires every year. It needs t Steps to renew the certificate: -1. Visit the [Apple developer account login page](https://appleid.apple.com/account?appId=632&returnUrl=https%3A%2F%2Fdeveloper.apple.com%2Fcontact%2F). +1. Visit the [Apple developer account login page](https://developer.apple.com/account). 2. Log in using the credentials stored in 1Password under **Apple developer account**. 3. Verify you are using the **Enterprise** subaccount for Fleet Device Management Inc. 4. Generate a new certificate following the instructions in [MicroMDM](https://github.com/micromdm/micromdm/blob/c7e70b94d0cfc7710e5c92be20d4534d9d5a0640/docs/user-guide/quickstart.md?plain=1#L103-L118). @@ -536,16 +535,28 @@ Upon receiving any device, follow these steps to process incoming equipment. ### Ship approved equipment -Once the Business Operations department approves inventory to be shipped from Fleet IT, follow these step to ship the equipment. +Once the Digital Experience department approves inventory to be shipped from Fleet IT, follow these step to ship the equipment. 1. Compare the equipment request issue with the ["Company equipment" spreadsheet](https://docs.google.com/spreadsheets/d/1hFlymLlRWIaWeVh14IRz03yE-ytBLfUaqVz0VVmmoGI/edit#gid=0) and verify physical inventory. 2. Plug in the device and ensure inventory has been correctly processed and all components are present (e.g. charger cord, power converter). -3. package equipment for shipment and include Yubikeys (if requested). +3. Package equipment for shipment and include Yubikeys (if requested). 4. Change the "Company equipment" spreadsheet to reflect the new user. - If you encounter any issues, repeat the [process incoming equipment steps](https://fleetdm.com/handbook/engineering#process-incoming-equipment). If problems persist, create a ["💻 IT support issue](https://github.com/fleetdm/confidential/issues/new?assignees=%40spokanemac&labels=%3Ahelp-it&projects=&template=request-it-support.md&title=%F0%9F%92%BB+Request+IT+support) for IT to troubleshoot the device. 6. Ship via FedEx to the address listed in the equipment request. 7. Add a comment to the equipment request issue, at-mentioning the requestor with the FedEx tracking info and close the issue. +### Provide 0-day support for major version macOS releases + +Beginning with macOS 16, Fleet will offer 0-day support for all major version macOS releases. + +1. Install major version macOS beta release on test devices. +2. Create a new [QA release issue](https://github.com/fleetdm/fleet/issues/new?assignees=xpkoala%2Cpezhub&labels=%23g-mdm%2C%23g-endpoint-ops%2C%3Arelease&projects=&template=release-qa.md&title=Release+QA%3A+macOS+16) with the new major version in the issue title. +3. Complete all manual smoke tests in the issue and confirm they are passing. +4. Confirm all automated tests are passing. +5. [File bugs](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=P1%2Cbug%2C%3Areproduce%2C%3Aincoming&projects=&template=bug-report.md&title=) with a `P1` label and assign to the appropriate [product group](https://fleetdm.com/handbook/company/product-groups#current-product-groups). +6. When all bugs are fixed, follow the [writing a feature guide](https://fleetdm.com/handbook/engineering#write-a-feature-guide) process to publish an article announcing Fleet 0-day support for the new major release. + + ## Rituals diff --git a/handbook/engineering/engineering.rituals.yml b/handbook/engineering/engineering.rituals.yml index 62432b78c3..bdc8aa69ec 100644 --- a/handbook/engineering/engineering.rituals.yml +++ b/handbook/engineering/engineering.rituals.yml @@ -46,7 +46,7 @@ task: "Release candidate ritual" startedOn: "2023-08-09" frequency: "Triweekly" - description: "Go through the process of create a release candidate." + description: "Go through the process of creating a release candidate." moreInfoUrl: "https://github.com/fleetdm/fleet/blob/main/tools/release/README.md#minor-release-typically-end-of-sprint" dri: "lukeheath" - @@ -74,14 +74,14 @@ task: "QA report" startedOn: "2023-08-09" frequency: "Triweekly" - description: "Every release cycle, on the Monday of release week, the DRI for the release ritual is updated on status of testing." + description: "Every release cycle, on the Monday of release week, update the DRI for the release ritual on status of testing." moreInfoUrl: dri: "xpkoala" - task: "Release QA" startedOn: "2023-08-09" frequency: "Triweekly" - description: "Every release cycle, by end of day Friday of release week, all issues move to Ready for release on the #g-mdm and #g-endpoint-ops sprint boards." + description: "Every release cycle, by end of day Friday of release week, move all issues to the ”✅ Ready for release” column on the #g-mdm and #g-endpoint-ops sprint boards." moreInfoUrl: dri: "xpkoala" #- @@ -95,8 +95,8 @@ task: "Check ongoing events" startedOn: "2024-02-09" frequency: "Daily" - description: "Check event issues and complete steps" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#book-an-event" + description: "Check event issues and complete steps." + moreInfoUrl: "https://fleetdm.com/handbook/engineering#book-an-event" dri: "spokanemac" diff --git a/handbook/finance/README.md b/handbook/finance/README.md new file mode 100644 index 0000000000..77d9bc9b67 --- /dev/null +++ b/handbook/finance/README.md @@ -0,0 +1,344 @@ +# Finance +This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. + +## Team +| Role | Contributor(s) | +|:------------------------------|:-----------------------------------------------------------------------------------------------------------| +| Head of Finance | [Joanne Stableford](https://www.linkedin.com/in/joanne-stableford/) _([@jostableford](https://github.com/JoStableford))_ +| Finance Engineer | [Isabell Reedy](https://www.linkedin.com/in/isabell-reedy-202aa3123/) _([@ireedy](https://github.com/ireedy))_ + + +## Contact us +- To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-finance&projects=&template=custom-request.md) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in [#g-finance](https://fleetdm.slack.com/archives/C047N5L6EGH). + - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-finance-63f3dc3cc931f6247fcf55a9/board?sprints=none) for this department, including pending tasks and the status of new requests. + + +## Responsibilities +The Finance department is directly responsible for accounts receivable including invoicing, accounts payable including commision calculations, exspense reporting including Brex memos and maintaining accurate spend projections in "🧮The numbers", sales taxes, payroll taxes, corporate income/franchise taxes, and financial operations including bank accounts and cash flow management. + + +### Run payroll +Many of these processes are automated, but it's vital to check Gusto and Plane manually for accuracy. + - Salaried fleeties are automated in Gusto and Plane. + - Hourly fleeties and consultants are a manual process each month in Gusto and Plane. + +| Payroll type | What to use | DRI | +|:-----------------------------|:-----------------------------|:-----------------------------| +| [Commissions and ramp](https://fleetdm.com/handbook/finance#run-us-commission-payroll) | "Off-cycle - Commission" payroll | Head of Finance +| Sign-on bonus | "Bonus" payroll | Head of Finance +| Performance bonus | "Bonus" payroll | Head of Finance +| Accelerations (quarterly) | "Off-cycle - Commission" payroll | Head of Finance +| [US contractor payroll](https://fleetdm.com/handbook/finance#run-us-contractor-payroll) | "Off-cycle" payroll | Head of Finance + +### Reconcile monthly recurring expenses +Recurring monthly or annual expenses, such as the tools we use throughout Fleet, are tracked as recurring, non-personnel expenses in ["🧮 The Numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) _(¶confidential Google Sheet)_, along with their payment source. Reconciliation of recurring expenses happens monthly. + +> Use this spreadsheet as the source of truth. Always make changes to it first before adding or removing a recurring expense. Only track significant expenses. (Other things besides amount can make a payment significant; like it being an individualized expense, for example.) + + +### Register Fleet as an employer with a new state +Fleet must register as an employer in any state where we hire new teammates. To do this, complete the following steps in Gusto: +1. After a new teammate completes their Gusto profile, the Finance department will be prompted to approve it for payroll. Sign in to your Gusto admin account and begin the approval process. +2. Select "yes" when prompted to file a new hire report and complete the approval process. +3. Once the profile is approved, navigate to Tax setup and select the state you’d like to register Fleet in. +4. Select “Have us register for you” and then “Start registration.” +5. Verify, add, and amend any company information to ensure accuracy. +6. Select “Send registration” and authorize payment for the specified amount. CorpNet will then send an email with next steps, which vary by state. +7. Update the [list of states that Fleet is currently registered with as an employer](https://fleetdm.com/handbook/finance#review-state-employment-tax-filings-for-the-previous-quarter). + + +### Process an email from a state agency +From time to time, you may get notices via email (or in the mail) from state agencies regarding Fleet's withholding and/or unemployment tax accounts. You can resolve some of these notices on your own by verifying and/or updating the settings in your Gusto account. + +If the notice is regarding an upcoming change to your deposit schedule or unemployment tax rate, make the required change in Gusto, such as: +- Update your unemployment tax rate. +- Update your federal deposit schedule. +- Update your state deposit schedule. + +In Gusto, you can click **How to review your notice** to help you understand what kind of notice you received and what additional action you can take to help speed up the time it takes to resolve the issue. + +> **Note:** Many agencies do not send notices to Gusto directly, so it’s important that you read and take action before any listed deadlines or effective dates of requested changes, in case you have to do something. If you can't resolve the notice on your own, are unsure what the notice is in reference to, or the tax notice has a missing payment or balance owed, follow the steps in the Report and upload a tax notice in Gusto. + +Every quarter, payroll and tax filings are due for each state. Gusto can handle these automatically if Third-party authorization (TPA) is enabled. Each state is unique and Gusto has a library of [State registration and resources](https://support.gusto.com/hub/Employers-and-admins/Taxes-forms-and-compliance/State-registration-and-resources) available to review. You will need to grant Third-party authorization (TPA) per state and this should be checked quarterly before the filing due dates to ensure that Gusto can file on time. --> + + +### Review state employment tax filings for the previous quarter + +Every quarter, payroll and tax filings are due for each state. Gusto automates this process, however there are often delays or quirks between Gusto's submission and the state receiving the filings. +To mitigate the risk of penalties and to ensure filings occur as expected, follow these steps in the first month of the new quarter, verifying past quarter submission: +1. Create an issue to "Review state filings for the previous quarter". +2. Copy this text block into the issue to track progress by state: + + +``` +States checked: +- [ ] California +- [ ] Colorado +- [ ] Connecticut +- [ ] Florida +- [ ] Georgia +- [ ] Hawaii +- [ ] Illinois +- [ ] Kansas +- [ ] Maryland +- [ ] Massachusetts +- [ ] New York +- [ ] Ohio +- [ ] Oregon +- [ ] Pennsylvania +- [ ] Rhode Island +- [ ] Tennessee +- [ ] Texas +- [ ] Utah +- [ ] Virginia +- [ ] Washington +- [ ] Washington, DC +- [ ] West Virginia +- [ ] Wisconsin +``` + + +3. Login to Gusto and navigate to "Taxes and compliance", then "Tax documents". +4. Login to each State portal (using the details saved in 1Password) and verify that the portal has received the automated submission from Gusto. +5. Check off states that are correct, and use comments to explain any quirks or remediation that's needed. + + +### Run US contractor payroll +For Fleet's US contractors, running payroll is a manual process: +1. Add the amount to be paid to the "Gross" line. +2. Review hours _("Time tools > Time tracking")_ +3. Adjust time frame to match current payroll period (the 27th through 26th of the month) +4. Sync hours and run contractor payroll. + +### Create an invoice +To create a new invoice for a Fleet customer, follow these steps: +1. Go to the [invoice folder in google drive](https://drive.google.com/drive/folders/11limC_KQYNYQPApPoXN0CplHo_5Qgi2b?usp=drive_link). +2. Create a copy of the invoice template, and title the copy `[invoice number] Fleet invoice - [customer name]`. + - The invoice number follows the format of `YYMMDD[daily issued invoice number]`, where the daily issued invoice number should equal `01` if it's the first invoice issued that day, `02` if it's the second, etc. +3. Edit the new invoice to reflect details from the signed subscription agreement (and PO if required). + - Enter the invoice number (and PO number if required) into the top right section of the invoice. + - Update the date of the invoice to reflect the current date. + - Make sure the payment terms match the signed subscription agreement. + - Copy the customer address from the signed subscription agreement and input it in the "Bill to" section of the invoice. + - Copy the "Billing contact" email from the signed subscription agreement and add it to the last line of the "Bill to" address. + - Make sure the start and end dates of the contract and amount match the subscription agreement. + - If professional services are included in the subscription agreement, include as a separate line in the invoice, and ensure the amounts total correctly. + - Ensure the "Notes" section has wiring instructions for payment via SVB. +4. Download the completed invoice as a PDF. +5. Send the PDF to the billing contact from the "Bill to" section of the invoice and cc [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays). Use the following template for the email: + +``` +Subject: Invoice for Fleet Device Management [invoice number] +Hello, + +I've attached the invoice for [customer name]'s purchase of Fleet Device Management's premium subscription. +For payment instructions please refer to your invoice, and reach out to [insert Fleet's billing address] with any questions. + +Thanks, +[name] +``` + +6. Update the opportunity and the opportunity billing cycle in Salesforce to include the "Invoice date" as the day the invoice was sent. +8. Notify the AE/CSM that the invoice has been sent. + +> Certain vendors require invoices submitted via a payment portal (such as Coupa). Once you've generated the invoice using the steps above, upload it to the relevant payment portal and email the billing contact to let them know you've submitted the invoice. + + +### Communicate the status of customer financial actions +This reporting is performed to update the status of open or upcoming customer actions regarding the financial health of the opportunity. To complete the report: +1. Check [SVB](https://connect.svb.com/#/) and [Brex](https://accounts.brex.com/login) for any recently received payments from customers and record them in SFDC. +2. Go to this [report folder](https://fleetdm.lightning.force.com/lightning/r/Folder/00lUG000000DstpYAC/view?queryScope=userFolders) in SFDC. The three reports will provide the data used in the report. +3. Copy the template below and paste it into the [#g-sales slack channel](https://fleetdm.slack.com/archives/C030A767HQV) and complete all "todos" using the data from Salesforce before sending. + +``` +Weekly revenue report - [@`todo: CRO` and @`todo: CEO`] +- Number accounts with outstanding balances = `todo` +- Number of customers awaiting invoices = `todo` +- Number of past-due renewals = `todo` +``` + +4. Send payment reminders via email to all outstanding accounts by responding to the invoice email initially sent to the customer. + +``` +Hello, +This is a reminder that you have an outstanding balance due for your Fleet Device Management premium subscription. +We have included the invoice here for your convenience. +For payment instructions please refer to your invoice, and reach out to [Fleet's billing contact] with any questions. + +Thanks, +[name] +``` + +5. If any accounts will become overdue within a week, reply in thread to the slack post, mention the opportunity owner of the account, and ask them to notify their contact that Fleet is still awaiting payment. +6. Review the [billing cycles](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000000yGjR2AU/view) report in SFDC for customers on multiyear deals. For any customers due for invoicing within the next week, create an issue on the Finance board. + + +### Run US commission payroll +1. Update individual teammates commission calculators (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with new revenue from any deals that are closed-won (have a subscription agreement signed by both parties) and have a **close date** within the previous month. + - Verify closed-won deal numbers with CRO to ensure any agreed upon exceptions are captured (eg: CRO approves an AE to receive commission on a renewal deal due to cross-sell). +2. In the "Monthly commission payroll party" meeting, present the commission calculations for Fleeties receiving commission for approval. + - If there are any quarterly accelerators due for the teammate receiving commission, ensure the individual total includes both the monthly and the quarterly amount. +3. After the amounts are approved in the meeting, process the commission payroll. + - Use the off-cycle payroll option in Gusto. Be sure to classify the payment as "Commission" in the "other earnings" field and not the generic "Bonus." +4. Once commission payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the commission as paid. + +### Run international commission payroll +1. Follow the steps in [run US commission payroll](https://fleetdm.com/handbook/finance#run-us-commission-payroll) to have the commission amounts approved by the CRO. +2. After the amounts are approved in the "Monthly commission payroll party", navigate to Help > Ask a question in Plane to request a commission payment for the teammate. +3. Send a message using the following template + + ``` + Hello, + I’d like to run an off-cycle commission payment for [teammate’s full name] for the period of [commission period]. + The amount of [USD amount] should be paid with their next payroll. + Please let me know if you need any additional information to process this request. + + Thanks, + [name] + ``` + +4. Once Plane confirms the payroll change has been actioned, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit#gid=928324236) to mark the commission as paid. + + +### Run quarterly or annual employee bonus payroll +1. Update individual teammate bonus calculator (linked from [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing)) with relevant metrics. + - Bonus plans will have details specified on how to measure success, with most drawing from the [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) or from linked SFDC reports. If unsure where to pull achievement metrics from, contact teammate's manager to clarify. +2. In the "Monthly commission payroll party" meeting, present the bonus calculations for Fleeties receiving bonus for approval. +3. After the amounts are approved in the meeting, process the bonus payroll. + - Use the off-cycle payroll option in Gusto and be sure to classify the payment as "Bonus". + - For international teammates, you may need to use the "Help" function, or email support to notify Plane of the amount needing to be paid. +4. Once bonus payroll has been run, update the [main commission calculator](https://docs.google.com/spreadsheets/d/1PuqUbfPGos87TfcHWgUd05TRJgQLlBmhyz1euj79m2A/edit?usp=sharing) to mark the bonus as paid. + + +### Process monthly accounting +Create a [new montly accounting issue](https://github.com/fleetdm/confidential/issues/new/choose) for the current month and year named "Closing out YYYY-MM" in GitHub and complete all of the tasks in the issue. (This uses the [monthly accounting issue template](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/5-monthly-accounting.md). + +- **SLA:** The monthly accounting issue should be completed and closed before the 7th of the month. +- The close date is tracked each month in [KPIs](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit). +- **When is the issue created?** We create and close the monthly accounting issue for the previous month within the first 7 days of the following month. For example, the monthly accounting issue to close out the month of January is created promptly in February and closed before the end of the day, Feb 7th. A convenient trick is to create the issue on the first Friday of the month and close it ASAP. + + +### Respond to low credit alert +Fleet admins will receive an email alert when the usage of company cards for the month is aproaching the company credit limit. To avoid the limit being exceeded, a Brex admin will follow these steps: +1. Sign in to Fleet's Brex account. +2. On the landing page, use the "Move money" button to "Add funds to your Brex business accounts". +3. Select "Transfer from a connected account" and select the primary business account. +4. Choose the "One time" transfer option and process the transfer. + +No further action needs to be taken, the amount available for use will increase without disruption to regular processes. + +### Check franchise tax status +No later than the second month of every quarter, we check [Delaware divison of corporations](https://icis.corp.delaware.gov) to ensure that Fleet has paid the quarterly franchise tax amounts to remain in good standing with the state of Delaware. +- Go to the [DCIS - eCorp website](https://icis.corp.delaware.gov/ecorp/logintax.aspx?FilingType=FranchiseTax) and use the details in 1Password to look up Fleet's status. +- If no outstanding amounts: the tax has been paid. +- If outstanding amounts shown: ensure payment before due date to avoid penalties, interest, and entering bad standing. + + +### Check finances for quirks +Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up on quirks. +1. Check to make sure [bookkeeping quirks](https://docs.google.com/spreadsheets/d/1nuUPMZb1z_lrbaQEcgjnxppnYv_GWOTTo4FMqLOlsWg/edit?usp=sharing) are all accounted for and resolved or in progress toward resolution. +2. Check balance sheet and profit and loss statements (P&Ls) in QBO against the latest [monthly workbooks](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM) in Google Drive. Ensure reports are in the "accural" accounting method. +3. Reach out to Pilot with any differences or quirks, and ask them to resolve/provide clarity. This often will need to happen over a call to review sycnhronously. +4. Once quirks are resolved, note the day it was resolved in the spreadsheet. + + +### Report quarterly numbers in Chronograph +Follow these steps to perform quarterly reporting for Fleet's investors: +1. Login to Chronograph and upload our profit and loss statement (P&L), balance sheet and cash flow statements for CRV (all in one book saved in [Google Drive](https://drive.google.com/drive/folders/1ben-xJgL5MlMJhIl2OeQpDjbk-pF6eJM). +2. Provide updated metrics for the following items using Fleet's [KPI spreadsheet](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0). + - Headcount at end of the previous quarter. + - Starting ARR for the previous quarter. + - Total new ARR for the previous quarter. + - "Upsell ARR" (new ARR from expansions only- Chronograph defines "upsell" as price increases for any reason. + **- Fleet does not "upsell" anything; we deliver more value and customers enroll more hosts), downgrade ARR and churn ARR (if any) for the previous quarter.** + - Ending ARR for the previous quarter. + - Starting number of customers, churned customers, and the number of new customers Fleet gained during the previous quarter. + - Total amount of Fleet customers at the end of the previous quarter. + - Gross margin % + - How to calculate: (total revenue for the quarter - cost of goods sold for the quarter)/total revenue for the quarter (these metrics can be found in our books from Pilot). Chronograph will automatically conver this number to a %. + - Net dollar retention rate + - How to calculate: (starting ARR + new subscriptions and expansions - churn)/starting ARR. + - Cash burn + - How to calculate: start of quarter runway - end of quarter runway. + + +### Deliver annual report for venture line +Within 60 days of the end of the year, follow these steps: +1. Provide Silicon Valley Bank (SVB) with our balance sheet and profit and loss statement (P&L, sometimes called a cashflow statement) for the past twelve months. +2. Provide SVB with our board-approved annual operating budgets and projections (on a quarterly granularity) for the new year. +3. Deliver this as early as possible in case they have questions. + + +### Process a new vendor invoice +Fleet pays its vendors in less than 15 business days in most cases. All invoices and tax documents should be submitted to the Finance department using the [appropriate Fleet email address (confidential Google Doc)](https://docs.google.com/document/d/1tE-NpNfw1icmU2MjYuBRib0VWBPVAdmq4NiCrpuI0F0/edit#heading=h.wqalwz1je6rq). +- After making sure the invoice received from a new vendor is valid, add the new vendor to the recurring expenses section of ["The numbers"](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) before paying the invoice. +- If we have not paid this vendor before, make sure we have received the required W-9 or W-8 form from the vendor. **Accounting cannot process a payment without these tax forms for compliance reasons.** + - **US-based vendors** are required to complete a [W-9 form](https://www.irs.gov/pub/irs-pdf/fw9.pdf). + - **Non-US based vendors and individuals** are required to follow these [instructions](https://www.irs.gov/instructions/iw8bene) and provide a completed [W-8BEN-E](https://www.irs.gov/pub/irs-pdf/fw8bene.pdf) form. + + +### Process a request to cancel a vendor +- Make the cancellation notification in accordance with the contract terms between Fleet and the vendor, typically these notifications are made via email and may have a specific address that notice must be sent to. If the vendor has an autorenew contract with Fleet there will often be a window of time in which Fleet can cancel, if notification is made after this time period Fleet may be obligated to pay for the subsequent year even if we don't use the vendor during the next contract term. +- Once cancelled, update the recurring expenses section of [The Numbers](https://docs.google.com/spreadsheets/d/1X-brkmUK7_Rgp7aq42drNcUg8ZipzEiS153uKZSabWc/edit#gid=2112277278) to reflect the cancellation by changing the projected monthly burn in column G to $0 and adding "CANCELLED" in front of the vendor's name in column C. + + +### Update weekly KPIs +- Create the weekly update issue from the template in ZenHub every Friday and update the [KPIs for finance](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit#gid=0) by 5pm US central time. + + +## Rituals + +The following table lists this department's rituals, frequency, and Directly Responsible Individual (DRI). + + + + + +#### Stubs +The following stubs are included only to make links backward compatible. + +##### Secure company-issued equipment for a team member +Please see [handbook/engineering#secure-company-issued-equipment-for-a-team-member](https://www.fleetdm.com/handbook/engineering#secure-company-issued-equipment-for-a-team-member). + +##### Register a domain for Fleet +Please see [handbook/register-a-domain-for-fleet](https://www.fleetdm.com/handbook/engineering#register-a-domain-for-fleet). + +##### Updating personnel details +Please see [handbook/engineering#update-personnel-details](https://www.fleetdm.com/handbook/engineering#update-personnel-details). + +##### Fix a laptop that's not checking in +Please see [handbook/engineering#fix-a-laptop-thats-not-checking-in](https://www.fleetdm.com/handbook/engineering#fix-a-laptop-thats-not-checking-in) + +##### Enroll a macOS host in dogfood +Please see [handbook/engineering#enroll-a-macos-host-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-macos-host-in-dogfood) + +##### Enroll a Windows or Ubuntu Linux device in dogfood +Please see [handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-windows-or-ubuntu-linux-device-in-dogfood) + +##### Enroll a ChromeOS device in dogfood +Please see [handbook/engineering#enroll-a-chromeos-device-in-dogfood](https://www.fleetdm.com/handbook/engineering#enroll-a-chromeos-device-in-dogfood) + +##### Lock a macOS host in dogfood using fleetctl CLI tool +Please see [handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool](https://www.fleetdm.com/handbook/engineering#lock-a-macos-host-in-dogfood-using-fleetctl-cli-tool) + +##### Book an event +Please see [handbook/engineering#book-an-event](https://www.fleetdm.com/handbook/engineering#book-an-event) + +##### Order SWAG +Please see [handbook/engineering#order-swag](https://www.fleetdm.com/handbook/engineering#order-swag) + + + + diff --git a/handbook/business-operations/business-operations.rituals.yml b/handbook/finance/finance.rituals.yml similarity index 57% rename from handbook/business-operations/business-operations.rituals.yml rename to handbook/finance/finance.rituals.yml index e6df744a0a..c96c59ce80 100644 --- a/handbook/business-operations/business-operations.rituals.yml +++ b/handbook/finance/finance.rituals.yml @@ -3,40 +3,30 @@ startedOn: "2024-02-12" frequency: "Weekly" description: "At the start of every week, check the Salesforce reports for past due invoices, non-invoiced opportunities, and past due renewals. Report findings to in the `#g-sales` channel." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#communicate-the-status-of-customer-financial-actions" + moreInfoUrl: "https://fleetdm.com/handbook/finance#communicate-the-status-of-customer-financial-actions" dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "AP invoice monitoring" startedOn: "2024-04-01" frequency: "Weekly" description: "Look for new accounts payable invoices and make sure that Fleet's suppliers are paid." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#process-a-new-vendor-invoice" + moreInfoUrl: "https://fleetdm.com/handbook/finance#process-a-new-vendor-invoice" dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" -- - task: "Inform managers about hours worked" - startedOn: "2024-02-09" - frequency: "Weekly" - description: "Gather hours worked for anyone who gets paid hourly by Fleet, and get those hours approved by their manager." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#inform-managers-about-hours-worked" - dri: "ireedy" - autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - - task: "KPI roundup + weekly update" + task: "Complete Finance KPI inputs" startedOn: "2024-02-16" frequency: "Weekly" - description: "Create the weekly KPI issue, complete the BizOps update and ensure all other inputs are completed on time." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#update-weekly-kpis" - dri: "hollidayn" + description: "Create the weekly team KPI issue, complete the finance update." + moreInfoUrl: "https://fleetdm.com/handbook/finance#update-weekly-kpis" + dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Key review prep" @@ -46,48 +36,48 @@ moreInfoUrl: "https://fleetdm.com/handbook/company/leadership#key-reviews" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "jostableford" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues - labels: [ "#g-business-operations" ] # label to be applied to issue + labels: [ "#g-finance" ] # label to be applied to issue repo: "confidential" # The GitHub repo that issues will be created in -- - task: "Vanta check" # TODO tie this to a responsibility - startedOn: "2024-04-01" - frequency: "Monthly" - description: "Look for any new actions in Vanta due in the upcoming months and create issues to ensure they're done on time." - moreInfoUrl: - dri: "jostableford" - autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" - task: "Reconcile monthly recurring expenses" startedOn: "2024-02-28" frequency: "Monthly" description: "Each month, update the inputs in “The numbers” spreadsheet to reflect the actuals for recurring non-personnel spend, and identify any unexpected increase or decrease in spend." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#reconcile-monthly-recurring-expenses" + moreInfoUrl: "https://fleetdm.com/handbook/finance#reconcile-monthly-recurring-expenses" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Monthly accounting" startedOn: "2024-02-28" frequency: "Monthly" description: "Create the monthly close GitHub issue and walk through the steps. This process includes fulfilling the monthly reporting requirement for SVB." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#process-monthly-accounting" - dri: "hollidayn" + moreInfoUrl: "https://fleetdm.com/handbook/finance#process-monthly-accounting" + dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" +- + task: "Run regular payroll" + startedOn: "2024-02-24" + frequency: "Monthly" + description: "Verify auto-populated payroll for all full time employees is accurate, and approve for processing." + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-payroll" + dri: "jostableford" + autoIssue: + labels: [ "#g-finance" ] + repo: "confidential" - task: "Monthly mail review" # TODO tie this to a responsibility startedOn: "2024-04-15" @@ -96,86 +86,62 @@ moreInfoUrl: null dri: "ireedy" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" -- - task: "Run regular payroll" - startedOn: "2024-02-24" - frequency: "Monthly" - description: "Verify auto-populated payroll for all full time employees is accurate, and approve for processing." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-payroll" - dri: "jostableford" - autoIssue: - labels: [ "#g-business-operations" ] - repo: "confidential" - task: "Run US contractor payroll" startedOn: "2024-02-28" frequency: "Monthly" description: "Manually process US contractor payroll by verifying and syncing time contractor worked, then processing payment." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-contractor-payroll" + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-contractor-payroll" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" - task: "Run US commission payroll" startedOn: "2024-01-31" frequency: "Monthly" description: "Verify closed-won deal amounts, use commission calculators to determine commissions owed, and process payroll." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-commission-payroll" dri: "jostableford" autoIssue: - labels: [ "#g-business-operations" ] + labels: [ "#g-finance" ] repo: "confidential" -- - task: "Recognize and benchmark workiversaries" - startedOn: "2024-07-15" - frequency: "Bimonthly" - description: "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#recognize-employee-workiversaries" - dri: "ireedy" - task: "Run bonus payroll" startedOn: "2024-01-31" frequency: "Quarterly" description: "Verify completion of any objective or outcome based bonus plans, and process payroll." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#run-us-commission-payroll" # TODO update linked process and add a new process that captures MBO payment + moreInfoUrl: "https://fleetdm.com/handbook/finance#run-us-commission-payroll" # TODO update linked process and add a new process that captures MBO payment dri: "jostableford" - task: "Review state filings for the previous quarter" startedOn: "2024-07-19" frequency: "Quarterly" description: "Verify that state filings have been successfully submitted for the previous quarter" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#review-state-employment-tax-filings-for-the-previous-quarter" + moreInfoUrl: "https://fleetdm.com/handbook/finance#review-state-employment-tax-filings-for-the-previous-quarter" dri: "ireedy" - task: "Investor reporting" startedOn: "2024-03-31" frequency: "Quarterly" description: "Provide updated metrics for CRV in Chronograph." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#report-quarterly-numbers-in-chronograph" - dri: "hollidayn" + moreInfoUrl: "https://fleetdm.com/handbook/finance#report-quarterly-numbers-in-chronograph" + dri: "ireedy" - task: "Quartlery finance check" startedOn: "2024-03-31" frequency: "Quarterly" description: "Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up with accounting providers for any quirks found." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#check-finances-for-quirks" + moreInfoUrl: "https://fleetdm.com/handbook/finance#check-finances-for-quirks" dri: "jostableford" -- - task: "Quarterly grants" - startedOn: "2024-02-01" - frequency: "Quarterly" - description: "Create the equity grants GitHub issue and walk through the steps." - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#grant-equity" - dri: "hollidayn" - task: "Deliver annual report for venture line" startedOn: "2024-12-01" frequency: "Annually" description: "Within 60 days of the new year, provide financial statements to SVB, along with board-approved projections for the new year" - moreInfoUrl: "https://fleetdm.com/handbook/business-operations#deliver-annual-report-for-venture-line" + moreInfoUrl: "https://fleetdm.com/handbook/finance#deliver-annual-report-for-venture-line" dri: "jostableford" - task: "Tax preparation" # TODO tie this to a responsibility @@ -183,4 +149,4 @@ frequency: "Annually" description: "Provide information to tax team with Deloitte and assist with filing and paying state and federal returns" moreInfoUrl: - dri: "hollidayn" + dri: "jostableford" diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index af31d259ee..2044bde60c 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -1,78 +1,85 @@ # Product Design + This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. + ## Team + | Role | Contributor(s) | |:---------------------------------|:-----------------------------------------------------------------------------------------------------------| | Head of Product Design | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/noahtalerman))_ | Product Designer | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_
    + ## Contact us + - To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?labels=%3Aproduct&title=Product%20design%20request%C2%BB______________________&template=custom-request.md) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in `#help-design`. - Please **use issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request. - - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-digital-experience-6451748b4eb15200131d4bab/board?sprints=none) for this department, including pending tasks and the status of new requests. + - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/-g-digital-experience-6451748b4eb15200131d4bab/board) for this department, including pending tasks and the status of new requests. + ## Responsibilities + The Product Design department is responsible for reviewing and collecting feedback from users, would-be users, and future users, prioritizing changes, designing the changes, and delivering these changes to the engineering team. Product Design prioritizes and shapes all changes involving functionality or usage, including the UI, REST API, command line, and webhooks. -### Release relavent Figma files -- Once your designs are reviewed and approved, change the status on the cover page of the relevant Figma file and move the issue to the "Settled" column. -- After each release (every 3 weeks) make sure you change the status on the cover page of the relevant Figma files that you worked on during the sprint to "Released". ->**Questions and missing information:** Take a screenshot of the area in Figma and add a comment in the story's GitHub issue. Figma does have a commenting system, but it is not easy to search for outstanding concerns and is therefore not preferred. -> ->For external contributors: please consider opening an issue with reference screenshots if you have a Figma related question you need to resolve. +### Drafting -### Create a new Figma file At Fleet, like [GitLab](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) and [other organizations](https://speakerdeck.com/mikermcneil/i-love-apis), every change to the product's UI gets [wireframed first](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach). -- Take the top user story that is assigned to you in the "Prioritized" column of the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). +- Take the top user story that is assigned to you in the "Ready" column of the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board) and move it to "In progress." -- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) Figma project. See [Working with Figma](https://fleetdm.com/handbook/product#working-with-figma) below for more details. - -- Use dev notes (component available in our library) to highlight important information to engineers and other teammates. +- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) Figma project by duplicating "\[TEMPLATE\] Starter file" (pinned to the top of the project). + +- The starter file includes 3 predefined pages: Cover, Ready, and Scratchpad. + - **Cover.** This page has a component with issue number, issue name, and status fields. There are 3 statuses: Work in progress, Approved, and Released (the main source of truth is still the drafting board). + - **Ready.** Use this page to communicate designs reviews and development. + - **Scratchpad.** Use this page for work in progress and design that might be useful in the future. + +- If the story requires API or YAML file changes, open a pull request to the reference docs release branch (e.g. `docs-v4.58.0`) with the proposed design. Mark the PR ready for review as soon as it's ready for feedback from the [API design DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris). + +- Add links to the user story as specified in the [issue template](https://github.com/fleetdm/fleet/issues/new?template=story.md). - Draft changes to the Fleet product that solve the problem specified in the story. Constantly place yourself in the shoes of a user while drafting changes. Place these drafts in the appropriate Figma file in Fleet product project. +- Use dev notes (component available in our library) to highlight important information to engineers and other teammates. + +- To help contributors find Figma wireframes for the area of the UI you're making changes to, add page names (ex. Host details page) to the user story's title and/or description. + - Be intentional about changes to design components (e.g. button border-radius or modal width) because these are expensive. They'll require code changes and QA in multiple parts of the product. Propose changes to a design component as part of an already-prioritized user story instead of [making a new request](#making-a-request) in 🎁🗣 Feature Fest. -- While drafting, reach out to sales, customer success, and demand for a business perspective. +- Reach out to sales, customer success, and demand for a business perspective. -- While drafting, engage engineering to gain insight into technical costs and feasibility. +- Engage engineering to gain insight into technical costs and feasibility. -When starting a new draft: -- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) project by duplicating "\[TEMPLATE\] Starter file" (pinned to the top of the project). -- Right-click on the duplicated file, select "Share", and ensure **anyone with the link** can view the file. -- Rename each Figma file to include the number and name of the corresponding issue on the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). (e.g. # 11766 Instructions for Autopilot enrollment). -- The starter file includes 3 predefined pages: Cover, Ready, and Scratchpad. - - **Cover.** This page has a component with issue number, issue name, and status fields. There are 3 statuses: Work in progress, Approved, and Released (the main source of truth is still the drafting board). - - **Ready.** Use this page to communicate designs reviews and development. - - **Scratchpad.** Use this page for work in progress and design that might be useful in the future. -- If the story requires API changes, open a draft PR with the proposed API design. - - These draft PRs are not actually merged, since they're often created weeks ahead of implementation and can artificially affect our PR open time KPI. Instead, once the documentation changes are ready for final review, the designer closes the draft PR and opens a fresh PR from the same branch. - -### Schedule a design review -- Prepare your draft in the user story issue. -- Prepare the agenda for your design review meeting, which should be an empty document other than the proposed changes you will present. -- Review the draft with the CEO at one of the daily design review meetings, or schedule an ad-hoc design review if you need to move faster. (Efficient access to design reviews on-demand [is a priority for Fleet's CEO](https://fleetdm.com/handbook/company/ceo). Emphasizing design helps us live our [empathy](https://fleetdm.com/handbook/company#empathy) value.) -- When introducing a story, clarify which review "mode" the CEO should operate in: - + **Final review** mode — you are 70% sure the design is 100% done. - + **Feedback** mode — you know the design is not ready for final review, but would like to get early feedback. Before bringing something in feedback mode consider whether the CEO will be best for giving feedback or if it would be better suited for someone else (engineer or PM). -- During the review meeting, take detailed notes of any feedback on the draft. -- Address the feedback by modifying your draft. -- Rinse and repeat at subsequent sessions until there is no more feedback. +- If the story has a requester and the title and/or description change during drafting (scope change), notify the requester. The customer DRI should confirm that the updated scope still meets the requester's needs. + +>**Questions, missing information, and notes:** Take a screenshot of the area in Figma and add a comment in the story's GitHub issue. Figma does have a commenting system, but it is not easy to search for outstanding concerns and is therefore not preferred. Also, commenting in Figma, sends all contributors email notifications. +> +>For external contributors: please consider opening an issue with reference screenshots if you have a Figma related question you need to resolve. + +### Prepare for design review + +1. Link to your draft in the user story issue. +2. Add the user story to the agenda for the [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews) meeting. +3. Attend design review or schedule an ad-hoc design review if you need to move faster. > As drafting occurs, inevitably, the requirements will change. The main description of the issue should be the single source of truth for the problem to be solved and the required outcome. The product manager is responsible for keeping the main description of the issue up-to-date. Comments and other items can and should be kept in the issue for historical record-keeping. + ### Ensure story drafting is complete - -Once a story has gone through design and is considered "Settled", it moves to the "Settled" column on the drafting board and assign to the Engineering Manager (EM). -Before assigning an EM to [estimate](https://fleetdm.com/handbook/engineering#sprint-ceremonies) a user story, the product designer ensures the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. +Once a story is approved in [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews), the Product Designer is responsible for moving the user story to the "Ready to spec" column, assigning the appropriate Engineering Manager (EM), adding a product group label, and changing the status on the cover page of the relevant Figma file to "Approved". + +The EM is responsible for moving the user story to the "Specified" and "Estimated" columns. + +Before assigning an EM, double-check that the "Product" section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete (no TODOs). + +Once a bug is approved in design review, The Product Designer is responsible for moving the bug to the appropriate release board. -Once a bug has gone through design and is considered "Settled", the designer removes the `:product` label and moves the issue to the 'Sprint backlog' column on the "Bugs" board and assigns the group engineering manager. ### Revise a draft currently in development + Expedited drafting is the revision of drafted changes currently being developed by the engineering team. Expedited drafting aims to quickly adapt to unknown edge cases and changing specifications while ensuring that Fleet meets our brand and quality guidelines. @@ -80,61 +87,32 @@ changing specifications while ensuring that Fleet meets our brand and quality gu You'll know it's time for expedited drafting when: - The team discovers that a drafted user story is missing crucial information that prevents contributors from continuing the development task. - A user story is taking more effort than was originally estimated, and Product Manager wants to find ways to cut aspects of planned functionality in order to still ship the improvement in the currently scheduled release. -- A user story on the drafting board won't reach "Settled" by the last estimation session in the current sprint and cannot wait until the next sprint. This can also happen when we decide to bring a user story in mid-sprint. +- A user story on the drafting board won't reach "Ready for spec" by the last estimation session in the current sprint and cannot wait until the next sprint. This can also happen when we decide to bring a user story in mid-sprint. What happens during expedited drafting? -1. If the user story wasn't "Settled" by the last estimation session, the product group's engineering manager (EM), [release DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris), and Head of Product Design are notified in `#g-mdm` or `#g-endpoint-ops`. Decision to allow the user story to make it into the sprint is up to the release DRI. -2. If the user story is already in the sprint, the EM, release DRI, and Head of Product Design are notified in `#g-mdm` or `#g-endpoint-ops`. If there are significant changes to the requirements, then the user story might be pushed to the next sprint. Decision is up to the release DRI. -3. If the release DRI decides the user story will be worked on this sprint, drafts are updated or finished. -4. UI changes [are approved](https://fleetdm.com/handbook/company/development-groups#drafting-process), and the UI changes are brought back into the sprint or are estimated. +1. If the story has a requester, notify the requester. The customer DRI should confirm that the updated scope still meets the requester's need. +2. If the user story wasn't "Ready for spec" by the last estimation session, the product group's engineering manager (EM), [release DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris), and Head of Product Design are notified in the `#g-mdm` or `#g-endpoint-ops` Slack channel. Decision to allow the user story to make it into the sprint is up to the release DRI. +3. If the user story is already in the sprint, the EM, release DRI, and Head of Product Design are notified in the `#g-mdm` or `#g-endpoint-ops` channel. If there are significant changes to the requirements, then the user story might be pushed to the next sprint. Decision is up to the release DRI. +4. If the release DRI decides the user story will be worked on this sprint, drafts are updated or finished. +5. UI changes [are approved](https://fleetdm.com/handbook/company/development-groups#drafting-process), and the UI changes are brought back into the sprint or are estimated. -### Correctly prioritize a bug -Bugs are always prioritized. (Fleet takes quality and stability [very seriously](https://fleetdm.com/handbook/company/why-this-way#why-spend-so-much-energy-responding-to-every-potential-production-incident).) Bugs should be prioritized in the following order: -1. Quality: product does what it's supposed to (what is documented). -2. Common-sense user criticality: If no one can load any page, that's obviously important. -3. Age of bugs: Long-open bugs are open wounds bleeding quality out of the product. They must be closed quickly. -4. Customer criticality: How important it is to a customer use case. - - -If a bug is unreleased or [critical](https://fleetdm.com/handbook/engineering#critical-bugs), it is addressed in the current sprint. Otherwise, it may be prioritized and estimated for the next sprint. If a bug [requires drafting](https://fleetdm.com/handbook/engineering#in-product-drafting-as-needed) to determine the expected functionality, the bug should undergo [expedited drafting](#expedited-drafting). - -If a bug is not addressed within six weeks, it is [sent to the product team for triage](https://fleetdm.com/handbook/engineering#in-engineering). Each sprint, the Head of Product Design reviews these bugs. Bugs are categorized as follows: -- **Schedule**: the bug should be prioritized in the next sprint if there's engineering capacity for it. -- **De-prioritized**: the issue will be closed and the necessary subsequent steps will be initiated. This might include updating documentation and informing the community. - -The Head of Product Design meets with the Director of Product Development to discuss and finalize the outcomes for the churned bugs. - -After aligning with the Director of Product Development on the outcomes, The Head of Product Design will clean up churned bugs. Below are the steps for each category: -- **Schedule**: Remove the `:product` label, move the bug ticket to the 'Sprint backlog' column on the bug board, and assign it to the appropriate group's Engineering Manager so that it can be prioritized into the sprint backlog. -- **De-prioritized**: The Head of Product Design should close the issue and, as the DRI, ensure all follow-up actions are finalized. ### Write a user story + Product Managers [write user stories](https://fleetdm.com/handbook/company/product-groups#writing-a-good-user-story) in the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). The drafting board is shared by every [product group](https://fleetdm.com/handbook/company/development-groups). + ### Draft a user story + Product Designers [draft user stories](https://fleetdm.com/handbook/company/product-groups#drafting) that have been prioritized by PMs. If the estimated user stories for a product group exceed [that group's capacity](https://fleetdm.com/handbook/company/product-groups#current-product-groups), all new design work for that group is paused, and the designer will contribute in other ways (documentation & handbook work, Figma maintenance, QA, etc.) until the PM deprioritizes estimated stories to make room, or until the next sprint begins. (If the designer has existing work-in-progress, they will continue to review and iterate on those designs and see the stories through to estimation.) If an issue's title or user story summary (_"as a…I want to…so that"_) does not match the intended change being discussed, the designer will move the issue to the "Needs clarity" column of the drafting board and assign the group product manager. The group product manager will revisit ASAP and edit the issue title and user story summary, then reassign the designer and move the issue back to the "Prioritized" column. Engineering Managers estimate user stories. They are responsible for delivering planned work in the current sprint (0-3 weeks) while quickly getting user stories estimated for the next sprint (3-6 weeks). Only work that is slated to be released into the hands of users within ≤six weeks will be estimated. Estimation is run by each group's Engineering Manager and occurs on the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). -### Rank features before release -These measures exist to keep all contributors (including other departments besides engineering and product) up to date with improvements and changes to the Fleet product. This helps folks plan and communicate with customers and users more effectively. - -After the kickoff of a product sprint, the demand and product teams decide which improvements are most important to highlight in this release, whether that's through social media "drumbeat" tweets, collaboration with partners, or emphasized [content blocks](https://about.gitlab.com/handbook/marketing/blog/release-posts/#3rd-to-10th) within the release blog post. - -When an improvement gets scheduled for release, the Head of Product sets its "echelon" to determine the emphasis the company will place on it. This leveling is based on the improvement's desirability and timeliness, and will affect demand effort for the feature. - -- **Echelon 1: A major product feature announcement.** The most important release types, these require a specific and custom demand package. Usually including an individual blog post, a demo video and potentially a press release or official product demand launch. There is a maximum of one _echelon 1_ product announcement per release sprint. -- **Echelon 2: A highlighted feature in the release notes.** This product feature will be highlighted at the top of the Sprint Release blog post. Depending on the feature specifics this will include: a 1-2 paragraph write-up of the feature, a demo video (if applicable) and a link to the docs. Ideally there would be no more than three _echelon 2_ features in a release post, otherwise the top features will be crowded. -- **Echelon 3: A notable feature to mention in the [changelog](https://github.com/fleetdm/fleet/blob/main/CHANGELOG.md)**. Most product improvements fit into this echelon. This includes 1-2 sentences in the changelog and [release blog post](https://fleetdm.com/releases). - -### Create release issue -Before each release, the Head of Product [creates a "Release" issue](https://github.com/fleetdm/confidential/issues/new/choose), which includes a list of all improvements included in the upcoming release. Each improvement links to the relevant bug or user story issue on GitHub so it is easy to read the related discussion and history. - -The product team is responsible for providing the demand team with the necessary information for writing the release blog post. Every three weeks after the sprint is kicked off, the product team meets with the relevant demand team members to go over the features for that sprint and recommend items to highlight as _echelon 2_ features and provide relevant context for other features to help demand decide which features to highlight. ### Consider a feature eligible to be flagged + At Fleet, features are placed behind feature flags if the changes could affect Fleet's availability of existing functionalities. The following highlights should be considered when deciding if we should leverage feature flags: - The feature flag must be disabled by default. @@ -146,19 +124,9 @@ At Fleet, features are placed behind feature flags if the changes could affect F > Fleet's feature flag guidelines is borrowed from GitLab's ["When to use feature flags" section](https://about.gitlab.com/handbook/product-development-flow/feature-flag-lifecycle/#when-to-use-feature-flags) of their handbook. Check out [GitLab's "Feature flags only when needed" video](https://www.youtube.com/watch?v=DQaGqyolOd8) for an explanation of the costs of introducing feature flags. -### Consider promoting a feature as "beta" -At Fleet, features are advertised as "beta" if there are concerns that the feature may not work as intended in certain Fleet -deployments. For example, these concerns could be related to the feature's performance in Fleet -deployments with hundreds of thousands of hosts. - -The following highlights should be considered when deciding if we promote a feature as "beta:" - -- The feature will not be advertised as "beta" permanently. This means that the Directly - Responsible Individual (DRI) who decides a feature is advertised as "beta" is also responsible for creating an issue that - explains why the feature is advertised as "beta" and tracking the feature's progress towards advertising the feature as "stable." -- The feature will be advertised as "beta" in the documentation on fleetdm.com/docs, release notes, release blog posts, and Twitter. ### View Fleet usage statistics + In order to understand the usage of the Fleet product, we [collect statistics](https://fleetdm.com/docs/using-fleet/usage-statistics) from installations where this functionality is enabled. Fleeties can view these statistics in the Google spreadsheet [Fleet @@ -252,9 +220,6 @@ Please see [handbook/product#create-release-issue](https://fleetdm.com/handbook/ ##### Feature flags Please see [handbook/product#consider-a-feature-eligible-to-be-flagged](https://fleetdm.com/handbook/product#consider-a-feature-eligible-to-be-flagged) -##### Beta features -Please see [handbook/product#consider-promoting-a-feature-as-beta](https://fleetdm.com/handbook/product#consider-promoting-a-feature-as-beta) - ##### Feature fest Please see [handbook/product-groups#feature-fest](https://fleetdm.com/handbook/product-groups#feature-fest) @@ -275,5 +240,11 @@ Please see [handbook/product-groups#after-the-feature-is-accepted](https://fleet ##### Restart Algolia manually Please see [handbook/digital-experience#restart-algolia-manually](https://fleetdm.com/handbook/digital-experience#restart-algolia-manually) +##### Schedule a design review +Please see [handbook/product#prepare-for-design-review](https://fleetdm.com/handbook/product#prepare-for-design-review) + +##### Create a new Figma file +Please see [handbook/product#drafting](https://fleetdm.com/handbook/product#drafting) + diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index c72a9a6207..b32f5406fe 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -16,7 +16,7 @@ task: "Design sprint kickoff" # 2024-03-06 TODO: Link to responsibility or corresponding "how to" info e.g. https://fleetdm.com/handbook/company/product-groups#making-changes 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." + description: "Add stories prioritized during Feature fest to Drafting board, assign stories to product designers, create upcoming reference docs release branch, and align on priorities." moreInfoUrl: dri: "noahtalerman" - @@ -30,7 +30,7 @@ task: "🦢🗣 Design review" 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." + description: "On Mondays, contributors present wireframes in 'Feedback' mode and anyone can give feedback. 'Final review' mode during all other days and only Head of Product Design + CTO + Product Designers give feedback." moreInfoUrl: "https://fleetdm.com/handbook/company/product-groups#design-reviews" dri: "noahtalerman" - @@ -67,10 +67,3 @@ frequency: "Triweekly" description: "Discuss what stories weren't completed in the previous sprint. Record the number of stories in KPIs. Align on priorities for upcoming sprint." dri: "noahtalerman" -- - task: "🦢🗣 Apple MDM maturity review" - startedOn: "2024-07-29" - frequency: "Weekly" - description: "Review stories in the “In review“ column on the drafting board with the “~apple-mdm-maturity“ label. Would this be usable for an IT admin and how does it compare to Jamf?" - moreInfoUrl: - dri: "noahtalerman" diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 9c86735f5d..c27cbc8785 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -8,7 +8,7 @@ This handbook page details processes specific to working [with](#contact-us) and | Role                                  | Contributor(s) | |:--------------------------------------|:------------------------------------------------------------------------------------------------------------------------| | Chief Revenue Officer (CRO) | [Alex Mitchell](https://www.linkedin.com/in/alexandercmitchell/) _([@alexmitchelliii](https://github.com/alexmitchelliii))_ -| Solutions Consulting (SC) | [Dave Herder](https://www.linkedin.com/in/daveherder/) _([@dherder](https://github.com/dherder))_
    [Zach Wasserman](https://www.linkedin.com/in/zacharywasserman/) _([@zwass](https://github.com/zwass))_ +| Solutions Consulting (SC) | [Dave Herder](https://www.linkedin.com/in/daveherder/) _([@dherder](https://github.com/dherder))_
    [Zach Wasserman](https://www.linkedin.com/in/zacharywasserman/) _([@zwass](https://github.com/zwass))_
    [Allen Houchins](https://www.linkedin.com/in/allenhouchins/) _([@allenhouchins](https://github.com/allenhouchins))_
    [Harrison Ravazzolo](https://www.linkedin.com/in/harrison-ravazzolo/) _([@harrisonravazzolo](https://github.com/harrisonravazzolo))_ | Channel Sales | [Tom Ostertag](https://www.linkedin.com/in/tom-ostertag-77212791/) _([@tomostertag](https://github.com/TomOstertag))_ | Account Executive (AE) | [Patricia Ambrus](https://www.linkedin.com/in/pambrus/) _([@ambrusps](https://github.com/ambrusps))_
    [Anthony Snyder](https://www.linkedin.com/in/anthonysnyder8/) _([@anthonysnyder8](https://github.com/AnthonySnyder8))_
    [Paul Tardif](https://www.linkedin.com/in/paul-t-750833/) _([@phtardif1](https://github.com/phtardif1))_ @@ -25,6 +25,13 @@ This handbook page details processes specific to working [with](#contact-us) and The Sales department is directly responsible for attaining the revenue goals of Fleet and helping to deliver upon our customers' objectives. +### Track an objection + +We often hear objections to using Fleet that are important to track, understand, and solve for. To track an objection: +1. Navigate to the ["Understanding objections document" (Confidential Google Doc)](https://docs.google.com/document/d/1UFjHaIBdoSGDiqNqwgxRdwRz9Wn9SqP7h-g2OM8Runk/edit). +2. Copy the template at the top of the page and paste it at the top of the "Objections" section completing all TODOs. + + ### Onboard a new sales team member Once the standard Fleetie onboarding issue is complete, create a new ["Sales team onboarding"](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-sales&projects=&template=sales-team-onboarding.md&title=Sales%20onboarding%3A_____________) issue and complete it. @@ -34,12 +41,12 @@ Once the standard Fleetie onboarding issue is complete, create a new ["Sales tea During the buying cycle, the champion will need to start the process to secure funding in cooperation with the economic buyer and the finance org. -All quotes and purchase orders must be approved by CRO before being sent to the prospect or customer. Often, the CRO will request Fleet business operations/legal of any unique terms required. +All quotes and purchase orders must be approved by CRO before being sent to the prospect or customer. Often, the CRO will request legal review of any unique terms required. The Fleet owner of the opportunity (usually AE or CSM) will prepare a quote and/or a Purchase Order when requested. - Because the champion may need to socialize "what is Fleet" or "what are we getting when buying Fleet," it is most often best to send the quote in [slide form](https://docs.google.com/presentation/d/15kbqm0OYPf1OmmTZvDp4F7VvMERnX4K6TMYqCYNr-wI/edit?usp=sharing). - Docusign can be used to create a [standard Purchase Order](https://www.loom.com/share/Loom-Message-16-January-2023-2ba8cf195ec645ebabac267d7df59823?sid=214f8c6b-beb3-427a-a3a8-e8c20b5dc350) if no special terms or pricing are needed. -- Before sending to prospect, work with the Business operations team to verify if sales tax needs to be charged and, if so, how much. +- Before sending to prospect, work with the Finance team to verify if sales tax needs to be charged and, if so, how much. ### Obtain a copy of Fleet's W-9 @@ -51,7 +58,9 @@ A recent signed copy of Fleet's W-9 form can be found in [this confidential PDF For customers with large deployments, Fleet accepts payment via wire transfer or electronic debit (ACH/SWIFT). -Provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. +Payment information for customers within the United States is on Fleet's invoices. Typically, payment information does not need to be sent separately. + +For Fleet customers outside of the United States or instances where a customer is requesting payment information prior to invoicing, provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. ### Review rep activity @@ -59,10 +68,21 @@ Provide remittance information to customers by exporting ["💸 Paying Fleet"](h Following up with people interested in Fleet is an important part of finding out whether or not they'd like to continue the process of buying the product. It is also very important not to be annoying. At Fleet, team members follow up with people, but not too often. To help coach reps and avoid being annoying to Fleet users, Fleet reviews rep activity on a regular basis following these steps: -1. In Salesforce, visit the activity report on your dashboard. (TODO: taylor will replace this and/or link it) +1. In Salesforce, visit the activity report on your dashboard. 2. For each rep, review recent activity from the last 30 days across all of that rep's accounts. 3. If outreach is too frequent or doesn't fit the company's strategy, then set up a 30 minute coaching session to discuss with the rep. +Every week, AEs will review the status of all qualified opportunities with leadership in an opportunity pipeline review meeting. For this meeting, reps will: +1. Update the following information in Salesforce for every opp: + - Contacts (and Roles) + - Amount + - Close date + - Stage + - Next steps +2. Make sure all contacts have been sent a connection request from Mike McNeil. +3. Identify and discuss where gaps are in [MEDDPICC](https://handbook.gitlab.com/handbook/sales/meddppicc/). +4. Relay how many meetings they had with attendees from both IT and security this week. + ### Validate Salesforce data (RevOps) @@ -123,6 +143,25 @@ To schedule an [ad hoc meeting](https://www.vocabulary.com/dictionary/ad%20hoc) - Ensure that the product category is defined ("Endpoint ops", "Device management", or "Vulnerability management") in the description of the issue. +### Conduct a POV + +We use the "tech eval test plan" as a guide when conducting a "POV" (Proof of Value) with a prospect. This planning helps us avoid costly detours that can take a long time, and result in folks getting lost. The tech eval test plan is the main document that will track success criteria for the tech eval. Before the Solutions Consultant (SC) creates a [tech eval issue](https://github.com/fleetdm/confidential/issues/new?assignees=dherder&labels=%23g-sales&projects=&template=technical-evaluation.md&title=Technical+evaluation%3A+___________________), the AE and SC will ask each other, at minimum, the following questions in order to enter the "Stage 3 - Requested POV" phase for the tech eval: +1. Do we have a well-defined set of technical criteria to test? +2. Do we have a timeline agreed upon? +3. What are the key business outcomes that will be verified as a result of completing the tech eval? + +If the above questions cannot be answered, the opportunity should not progress to tech eval. Once the opportunity moves to the "Stage 3 - Requested POV" phase in Salesforce, automation will generate the tech eval test plan. This doc will exist in Google Drive> Sales> Opportunities> "Account Name". + +Once there is agreement to proceed with the tech eval and success criteria have been defined and documented, follow this process: +1. SC creates a [tech eval issue](https://github.com/fleetdm/confidential/issues/new?assignees=dherder&labels=%23g-sales&projects=&template=technical-evaluation.md&title=Technical+evaluation%3A+___________________). +2. SC updates the issue labels to include: "~sc, :tech-eval" and the obfuscated "prospect-codename" label. See [Assign a customer a codename](https://fleetdm.com/handbook/customer-success#assign-a-customer-codename). Instead of + "customer-codename", prospects are labeled "prospect-codename". When a prospect purchases Fleet, the SC will edit this label from "prospect-codename" to "customer-codename". +3. SC sets the appropriate sprint duration based on the defined timelines and an estimation of effort in points. +4. SC converts the issue to an Epic. All issues related to this prospect tech eval (ie: cloud instance deployments, etc.) should be added to the newly created epic. +5. All check-in meetings and notes taken are documented in the tech eval test plan document. Any TODO item will be added as a comment to the tech eval issue epic. +6. The SC presents the tech eval test plan and feature tracker used for the tech eval to the CS team upon the prospect's transition to Fleet customer. + + ### Hand off a technical evaluation to a temporary DRI Tech evals will have a DRI at all times; should the DRI be unavailable (ie: vacation), a hand off process to a temporary DRI will be required. In advance of vacation time (target one week in advance), refer to the following examples and review with each individual that will act as the temporary DRI for the technical evaluation while you are away. This can be documented as a google doc or can be added to the relevant tech eval epic issue in github. @@ -179,7 +218,7 @@ Temp Transfer to: Temp technical DRI 1. If a customer has no objections to using Fleet's NDA, route the NDA to them for signature using the "🙊 NDA (Non-disclosure agreement)" template in [DocuSign](https://apps.docusign.com/send/home). > If a customer would like to review the NDA first, download a .docx of [Fleet's NDA](https://docs.google.com/document/d/1gQCrF3silBFG9dJgyCvpmLa6hPhX_T4V7pL3XAwgqEU/edit?usp=sharing) and send it to the customer. 2. If the customer has no objections, route the NDA using the template in DocuSign (do not upload and use the copy you emailed to the customer). -3. If the customer "redlines" (i.e. wants to change) the NDA, follow the [contract review process](https://fleetdm.com/handbook/company/communications#getting-a-contract-reviewed) so that BizOps can look over any proposed changes and provide guidance on how to proceed. +3. If the customer "redlines" (i.e. wants to change) the NDA, follow the [contract review process](https://fleetdm.com/handbook/company/communications#getting-a-contract-reviewed) so that Digital Experience can look over any proposed changes and provide guidance on how to proceed. ### Create a customer agreement @@ -192,12 +231,12 @@ Temp Transfer to: Temp technical DRI - **Standard terms:** For all subscription agreements, NDAs, and similar contracts, Fleet maintains a [standard set of terms and maximum allowable adjustments for those terms](https://docs.google.com/spreadsheets/d/1gAenC948YWG2NwcaVHleUvX0LzS8suyMFpjaBqxHQNg/edit#gid=1136345578). Exceptions to these maximum allowable adjustments always require CEO approval, whether in the form of redlines to Fleet's agreements or in terms on a prospective customer's own contract. -> All non-standard (from another party) subscription agreements, NDAs, and similar contracts require legal review from the Business Operations department before being signed. [Create an issue to request legal review](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/contract-review.md). +> All non-standard (from another party) subscription agreements, NDAs, and similar contracts require legal review from the Contracts and Compliance department before being signed. [Create an issue to request legal review](https://github.com/fleetdm/confidential/blob/main/.github/ISSUE_TEMPLATE/contract-review.md). ### Close a new customer deal -To close a deal with a new customer (non-self-service), create and complete a GitHub issue using the ["Sale" issue template](https://github.com/fleetdm/confidential/issues/new?assignees=hughestaylor&labels=%23g-business-operations&projects=&template=3-sale.md&title=New+customer%3A+_____________). +To close a deal with a new customer (non-self-service), create and complete a GitHub issue using the ["Sale" issue template](https://github.com/fleetdm/confidential/issues/new?assignees=alexmitchelliii&labels=%23g-sales&projects=&template=3-sale.md&title=New+customer%3A+_____________). ### Change customer credit card number @@ -207,8 +246,8 @@ You can help a Premium license dispenser customers change their credit card by d ### Process a security questionnaire -- The AE will [use the handbook](https://fleetdm.com/handbook/company/communications#vendor-questionnaires) to answer most of the questions with links to appropriate sections in the handbook. After this first pass has been completed, and if there are outstanding questions, the AE will [assign the issue to Business Operations (#g-business-operations)](https://fleetdm.com/handbook/business-operations#contact-us) with a requested timeline for completion defined. -- BizOps consults the handbook to validate that nothing was missed by the AE. After the second pass has been completed, and if there are outstanding questions, BizOps will [reassign the issue to Sales (#g-sales)](https://fleetdm.com/handbook/sales#contact-us) for intake. +- The AE will [use the handbook](https://fleetdm.com/handbook/company/communications#vendor-questionnaires) to answer most of the questions with links to appropriate sections in the handbook. After this first pass has been completed, and if there are outstanding questions, the AE will [assign the issue to Digital Experience (#g-digital-experience)](https://fleetdm.com/handbook/digital-experience#contact-us) with a requested timeline for completion defined. +- Digital Experience consults the handbook to validate that nothing was missed by the AE. After the second pass has been completed, and if there are outstanding questions, Digital Experience will [reassign the issue to Sales (#g-sales)](https://fleetdm.com/handbook/sales#contact-us) for intake. - The issue will be assigned to the Solutions Consultant (SC) associated to the opportunity in order to complete any unanswered questions. - The SC will search for unanswered questions and confirm again that nothing was missed from the handbook. Content missing from the handbook will need to be added via PR by the SC. Any unanswered questions after this pass has been completed by the SC will need to be [escalated to the Infrastructure team (#g-customer-success)](https://fleetdm.com/handbook/customer-success#contact-us) with the requested timeline for completion defined in the issue. Once complete, the infra team will assign the issue back to the #g-sales board. - Any questions answered by the infra team will be added to the handbook by the SC. diff --git a/handbook/sales/sales.rituals.yml b/handbook/sales/sales.rituals.yml index 6b2fd8fd0d..e83a43e851 100644 --- a/handbook/sales/sales.rituals.yml +++ b/handbook/sales/sales.rituals.yml @@ -1,12 +1,18 @@ # https://github.com/fleetdm/fleet/pull/13084 - + - + task: "Close leads contacted ≥7 days ago" + startedOn: "2024-07-05" + frequency: "Daily" + description: "Close all of your leads in the 'Attempted to contact' stage and which have been there for 7 or more days. If follow-up is appropriate, and won't be bothersome, it can be done after closing the lead. (A new lead can always be opened for the contact later.)" + moreInfoUrl: "" + dri: "Every AE" - task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "alexmitchelliii" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues diff --git a/infrastructure/dogfood/terraform/aws-tf-module/free.tf b/infrastructure/dogfood/terraform/aws-tf-module/free.tf index 308db90bfa..37eb03f37e 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/free.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/free.tf @@ -25,7 +25,7 @@ module "free" { } rds_config = { name = local.customer_free - engine_version = "8.0.mysql_aurora.3.05.2" + engine_version = "8.0.mysql_aurora.3.07.1" snapshot_identifier = "arn:aws:rds:us-east-2:611884880216:cluster-snapshot:a2023-03-06-pre-migration" db_parameters = { # 8mb up from 262144 (256k) default diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf index 4256d37d25..2bede111ec 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf @@ -76,7 +76,7 @@ module "main" { } rds_config = { name = local.customer - engine_version = "8.0.mysql_aurora.3.05.2" + engine_version = "8.0.mysql_aurora.3.07.1" snapshot_identifier = "arn:aws:rds:us-east-2:611884880216:cluster-snapshot:a2023-03-06-pre-migration" db_parameters = { # 8mb up from 262144 (256k) default diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index 4329e1f0b4..db7a79e5e1 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.54.1" + default = "fleetdm/fleet:v4.56.0" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index 0850224609..ba81f4af53 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,7 +68,7 @@ variable "redis_mem" { } variable "image" { - default = "fleetdm/fleet:v4.54.1" + default = "fleetdm/fleet:v4.56.0" } variable "software_installers_bucket_name" { diff --git a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile index 18936fa2d8..c232760b44 100644 --- a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile +++ b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.4-alpine3.20@sha256:ace6cc3fe58d0c7b12303c57afe6d6724851152df55e08057b43990b927ad5e8 +FROM golang:1.23.1-alpine3.20@sha256:436e2d978524b15498b98faa367553ba6c3655671226f500c72ceb7afb2ef0b1 ARG TAG RUN apk add git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/cmd/osquery-perf/ && go build . diff --git a/infrastructure/loadtesting/terraform/ecs.tf b/infrastructure/loadtesting/terraform/ecs.tf index cce392657a..2327c2787b 100644 --- a/infrastructure/loadtesting/terraform/ecs.tf +++ b/infrastructure/loadtesting/terraform/ecs.tf @@ -203,7 +203,11 @@ resource "aws_ecs_task_definition" "backend" { { name = "FLEET_OSQUERY_ASYNC_HOST_REDIS_SCAN_KEYS_COUNT" value = "10000" - } + }, + { + name = "FLEET_S3_SOFTWARE_INSTALLERS_BUCKET" + value = aws_s3_bucket.software_installers.bucket + }, ], local.additional_env_vars) } ]) @@ -329,18 +333,18 @@ resource "aws_appautoscaling_policy" "ecs_policy_cpu" { resource "random_password" "fleet_server_private_key" { length = 32 special = true -} - -resource "aws_secretsmanager_secret" "fleet_server_private_key" { +} + +resource "aws_secretsmanager_secret" "fleet_server_private_key" { name = "${terraform.workspace}-fleet-server-private-key" recovery_window_in_days = "0" lifecycle { create_before_destroy = true } -} - +} + resource "aws_secretsmanager_secret_version" "fleet_server_private_key" { secret_id = aws_secretsmanager_secret.fleet_server_private_key.id secret_string = random_password.fleet_server_private_key.result -} +} diff --git a/infrastructure/loadtesting/terraform/rds.tf b/infrastructure/loadtesting/terraform/rds.tf index 4276c0d81c..b70d4de1cf 100644 --- a/infrastructure/loadtesting/terraform/rds.tf +++ b/infrastructure/loadtesting/terraform/rds.tf @@ -26,10 +26,10 @@ module "aurora_mysql" { #tfsec:ignore:aws-rds-enable-performance-insights-encryp source = "terraform-aws-modules/rds-aurora/aws" version = "7.7.1" - name = "${local.name}-mysql" - engine = "aurora-mysql" - engine_version = "8.0.mysql_aurora.3.03.3" - instance_class = var.db_instance_type + name = "${local.name}-mysql" + engine = "aurora-mysql" + engine_version = "8.0.mysql_aurora.3.05.2" + instance_class = var.db_instance_type instances = { one = {} diff --git a/infrastructure/loadtesting/terraform/readme.md b/infrastructure/loadtesting/terraform/readme.md index 9e14ad8d0e..bfa120bfa1 100644 --- a/infrastructure/loadtesting/terraform/readme.md +++ b/infrastructure/loadtesting/terraform/readme.md @@ -61,13 +61,14 @@ If you need to run a load test with MDM enabled and configured you will need to 2. Then set the `fleet_config` terraform var the following way (make sure to add any extra configuration you need to this JSON): ```sh -export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' +export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' ``` - The above is needed because the newline characters in the certificate/key/token files. - The value set in `FLEET_MDM_APPLE_SCEP_CHALLENGE` must match whatever you set in `osquery-perf`'s `mdm_scep_challenge` argument. - The above `export TF_VAR_fleet_config=...` command was tested on `bash`. It did not work in `zsh`. - Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_PUSH=1`. We don't want to generate push notifications against fake UUIDs (otherwise it may cause Apple to rate limit due to invalid requests). +- Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY=1` to skip verification of Apple certificates for OTA enrollments. This has an impact on real devices because they will not be notified of any command to execute (it may take a reboot for them to reach out to Fleet for more commands). 3. Add the following `osquery-perf` arguments to [loadtesting.tf](./loadtesting.tf) @@ -97,8 +98,8 @@ There are a few main places of interest to monitor the load and resource usage: You can deploy new code changes to an environment the following way: -1. Push the code changes to the `BRANCH_NAME` and wait for the [Docker publish](https://github.com/fleetdm/fleet/actions/workflows/goreleaser-snapshot-fleet.yaml) action to complete. -2. Find the docker image ID corresponding to your branch: +1. Push the code changes to the `BRANCH_NAME`, trigger a manual run of the [Docker publish](https://github.com/fleetdm/fleet/actions/workflows/goreleaser-snapshot-fleet.yaml) workflow (make sure to select the branch) and wait for it to complete. +2. Find the docker image IDs corresponding to your branch: ```sh docker images | grep 'BRANCH_NAME' | awk '{print $3}' ``` diff --git a/infrastructure/loadtesting/terraform/s3.tf b/infrastructure/loadtesting/terraform/s3.tf new file mode 100644 index 0000000000..ca15b37dba --- /dev/null +++ b/infrastructure/loadtesting/terraform/s3.tf @@ -0,0 +1,46 @@ +data "aws_iam_policy_document" "software_installers" { + statement { + actions = [ + "s3:GetObject*", + "s3:PutObject*", + "s3:ListBucket*", + "s3:ListMultipartUploadParts*", + "s3:DeleteObject", + "s3:CreateMultipartUpload", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + "s3:GetBucketLocation" + ] + resources = [aws_s3_bucket.software_installers.arn, "${aws_s3_bucket.software_installers.arn}/*"] + } +} + +resource "aws_iam_policy" "software_installers" { + policy = data.aws_iam_policy_document.software_installers.json +} + +resource "aws_iam_role_policy_attachment" "software_installers" { + policy_arn = aws_iam_policy.software_installers.arn + role = aws_iam_role.main.name +} + +resource "aws_s3_bucket" "software_installers" { #tfsec:ignore:aws-s3-encryption-customer-key:exp:2022-07-01 #tfsec:ignore:aws-s3-enable-versioning #tfsec:ignore:aws-s3-enable-bucket-logging:exp:2022-06-15 + bucket_prefix = terraform.workspace +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "software_installers" { + bucket = aws_s3_bucket.software_installers.bucket + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "aws:kms" + } + } +} + +resource "aws_s3_bucket_public_access_block" "software_installers" { + bucket = aws_s3_bucket.software_installers.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} diff --git a/infrastructure/render/README.md b/infrastructure/render/README.md index 23bd1618d0..2a464c75ae 100644 --- a/infrastructure/render/README.md +++ b/infrastructure/render/README.md @@ -1,43 +1,43 @@ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/fleetdm/fleet) -# Fleet Deployment Guide +# Fleet deployment guide This guide outlines the services configured in the Render blueprint for deploying the Fleet system, which includes a web service, a MySQL database, and a Redis server. -## Services Overview +## Services overview -### 1. Fleet Web Service +### 1. Fleet web service - **Type:** Web - **Runtime:** Image - **Image:** `fleetdm/fleet:latest` - **Description:** Main web service running the Fleet application, which is deployed using the latest Fleet Docker image. Configured to prepare the database before deployment. -- **Health Check Path:** `/healthz` -- **Environment Variables:** Connects to MySQL and Redis using service-bound environment variables. +- **Health check path:** `/healthz` +- **Environment variables:** Connects to MySQL and Redis using service-bound environment variables. -### 2. Fleet MySQL Database -- **Type:** Private Service (pserv) +### 2. Fleet MySQL database +- **Type:** Private service (pserv) - **Runtime:** Docker - **Repository:** [MySQL Example on Render](https://github.com/render-examples/mysql) - **Disk:** 10 GB mounted at `/var/lib/mysql` - **Description:** MySQL database used by the Fleet web service. Environment variables for database credentials are managed within the service and some are automatically generated. -### 3. Fleet Redis Service -- **Type:** Private Service (pserv) +### 3. Fleet Redis service +- **Type:** Private service (pserv) - **Runtime:** Image - **Repository:** [Redis Docker image](https://hub.docker.com/_/redis) - **Description:** Redis service for caching and other in-memory data storage needs of the Fleet web service. -## Deployment Guide +## Deployment guide ### Prerequisites - You need an account on [Render](https://render.com). - Familiarity with Render's dashboard and deployment concepts. -### Steps to Deploy +### Steps to deploy Click the deploy on render button or import the blueprint from the Render service deployment dashboard. -### Post-Deployment +### Post-deployment Navigate to the generated URL and run through the initial setup. If you have a license key you can add it post-deploy as an environment variable `FLEET_LICENSE_KEY=value` in the Fleet service configuration. diff --git a/it-and-security/default.yml b/it-and-security/default.yml index c94732459b..52baadb564 100644 --- a/it-and-security/default.yml +++ b/it-and-security/default.yml @@ -38,6 +38,10 @@ org_settings: zendesk: [ ] mdm: apple_bm_default_team: $DOGFOOD_APPLE_BM_DEFAULT_TEAM + end_user_authentication: + entity_id: dogfood-eula.fleetdm.com + idp_name: Google Workspace + metadata_url: $DOGFOOD_MDM_SSO_METADATA_URL org_info: contact_url: https://fleetdm.com/company/contact org_logo_url: "" @@ -86,3 +90,4 @@ org_settings: policies: queries: - path: ./lib/collect-fleetd-update-channels.queries.yml +software: diff --git a/it-and-security/lib/configuration-profiles/macos-ensure-show-status-bar-is-enabled.mobileconfig b/it-and-security/lib/configuration-profiles/macos-ensure-show-status-bar-is-enabled.mobileconfig new file mode 100644 index 0000000000..393f4ffbb5 --- /dev/null +++ b/it-and-security/lib/configuration-profiles/macos-ensure-show-status-bar-is-enabled.mobileconfig @@ -0,0 +1,37 @@ + + + + + PayloadContent + + + PayloadDisplayName + Ensure Show Status Bar Is Enabled + PayloadType + com.apple.Safari + PayloadIdentifier + com.fleetdm.cis-ensure-show-status-bar-is-enabled + PayloadUUID + 708B39DB-E2B7-405C-A523-88F3DDC8DFFC + ShowOverlayStatusBar + + + + PayloadDescription + Ensure Show Status Bar Is Enabled + PayloadDisplayName + Ensure Show Status Bar Is Enabled + PayloadIdentifier + com.fleetdm.cis-ensure-show-status-bar-is-enabled + PayloadRemovalDisallowed + + PayloadScope + System + PayloadType + Configuration + PayloadUUID + 00FB5D02-8044-4E6F-884C-D73E7A32A2E7 + PayloadVersion + 1 + + diff --git a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json index dabb02d1a6..f4811f765b 100644 --- a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json +++ b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json @@ -2,7 +2,7 @@ "Type": "com.apple.configuration.passcode.settings", "Identifier": "956e0d14-6019-479b-a6f9-a69ef77668c5", "Payload": { - "MaximumFailedAttempts": "five", + "MaximumFailedAttempts": 5, "MaximumInactivityInMinutes ": 5, "MinimumLength ": 12, "MinimumComplexCharacters": 3 diff --git a/it-and-security/lib/configuration-profiles/windows-firewall.xml b/it-and-security/lib/configuration-profiles/windows-firewall.xml new file mode 100644 index 0000000000..424d89f886 --- /dev/null +++ b/it-and-security/lib/configuration-profiles/windows-firewall.xml @@ -0,0 +1,25 @@ + + + + + bool + + + ./Vendor/MSFT/Firewall/MdmStore/DomainProfile/EnableFirewall + + true + + + + + + + bool + + + ./Vendor/MSFT/Firewall/MdmStore/DomainProfile/DisableStealthMode + + + true + + diff --git a/it-and-security/lib/configuration-profiles/windows-password.xml b/it-and-security/lib/configuration-profiles/windows-password.xml new file mode 100644 index 0000000000..ce9c96ff9c --- /dev/null +++ b/it-and-security/lib/configuration-profiles/windows-password.xml @@ -0,0 +1,24 @@ + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordLength + + 10 + + + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MinDevicePasswordComplexCharacters + + 2 + + diff --git a/it-and-security/lib/configuration-profiles/windows-screen-lock.xml b/it-and-security/lib/configuration-profiles/windows-screen-lock.xml new file mode 100644 index 0000000000..f7d95aa803 --- /dev/null +++ b/it-and-security/lib/configuration-profiles/windows-screen-lock.xml @@ -0,0 +1,24 @@ + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/DevicePasswordEnabled + + 0 + + + + + + + int + + + ./Device/Vendor/MSFT/Policy/Config/DeviceLock/MaxInactivityTimeDeviceLock + + 15 + + diff --git a/it-and-security/lib/explore-data.queries.yml b/it-and-security/lib/explore-data.queries.yml deleted file mode 100644 index deb070644d..0000000000 --- a/it-and-security/lib/explore-data.queries.yml +++ /dev/null @@ -1,3210 +0,0 @@ -- 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/teams/company-owned-ipads.yml b/it-and-security/teams/company-owned-ipads.yml index ef8c46a47b..6209f77663 100644 --- a/it-and-security/teams/company-owned-ipads.yml +++ b/it-and-security/teams/company-owned-ipads.yml @@ -11,8 +11,15 @@ team_settings: enable_calendar_events: false agent_options: controls: + ipados_updates: + deadline: "2024-08-23" + minimum_version: "17.6" macos_settings: custom_settings: scripts: policies: -queries: \ No newline at end of file +queries: +software: + app_store_apps: + - app_store_id: '618783545' # Slack + - app_store_id: '546505307' # Zoom diff --git a/it-and-security/teams/company-owned-iphones.yml b/it-and-security/teams/company-owned-iphones.yml index badc8fa942..1b1ed229d1 100644 --- a/it-and-security/teams/company-owned-iphones.yml +++ b/it-and-security/teams/company-owned-iphones.yml @@ -11,6 +11,9 @@ team_settings: enable_calendar_events: false agent_options: controls: + ios_updates: + deadline: "2024-08-23" + minimum_version: "17.6" macos_settings: custom_settings: - path: ../lib/configuration-profiles/ios-restrictions.mobileconfig @@ -20,3 +23,7 @@ controls: scripts: policies: queries: +software: + app_store_apps: + - app_store_id: '618783545' # Slack + - app_store_id: '546505307' # Zoom diff --git a/it-and-security/teams/compliance-exclusions.yml b/it-and-security/teams/compliance-exclusions.yml index bf5a3896f7..fde00d1aea 100644 --- a/it-and-security/teams/compliance-exclusions.yml +++ b/it-and-security/teams/compliance-exclusions.yml @@ -9,7 +9,25 @@ team_settings: secrets: - secret: $DOGFOOD_COMPLIANCE_EXCLUSIONS_ENROLL_SECRET agent_options: - path: ../lib/agent-options.yml + 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: / + update_channels: + # We want to use these hosts to smoke test edge releases. + osqueryd: edge + orbit: edge + desktop: edge controls: policies: queries: +software: diff --git a/it-and-security/teams/explore-data.yml b/it-and-security/teams/explore-data.yml deleted file mode 100644 index abea724c2a..0000000000 --- a/it-and-security/teams/explore-data.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: "Explore data (fleetdm.com)" -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 index 4911176def..e324a8ab4d 100644 --- a/it-and-security/teams/servers-canary.yml +++ b/it-and-security/teams/servers-canary.yml @@ -29,3 +29,4 @@ controls: scripts: policies: queries: +software: diff --git a/it-and-security/teams/servers.yml b/it-and-security/teams/servers.yml index c43085a695..5d9e4586a6 100644 --- a/it-and-security/teams/servers.yml +++ b/it-and-security/teams/servers.yml @@ -29,3 +29,4 @@ controls: scripts: policies: queries: +software: diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index 27ab9430ee..1d7ea39710 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -65,7 +65,6 @@ 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 @@ -88,15 +87,19 @@ controls: - path: ../lib/configuration-profiles/macos-secure-terminal-keyboard.mobileconfig - path: ../lib/configuration-profiles/macos-disable-update-notifications.mobileconfig - path: ../lib/configuration-profiles/passcode-settings-ddm.json + - path: ../lib/configuration-profiles/macos-ensure-show-status-bar-is-enabled.mobileconfig macos_setup: bootstrap_package: "" enable_end_user_authentication: true macos_setup_assistant: null macos_updates: - deadline: "" - minimum_version: "" + deadline: "2024-08-23" + minimum_version: "14.6.1" windows_settings: - custom_settings: null + custom_settings: + - path: ../lib/configuration-profiles/windows-firewall.xml + - path: ../lib/configuration-profiles/windows-password.xml + - path: ../lib/configuration-profiles/windows-screen-lock.xml windows_updates: deadline_days: 7 grace_period_days: 2 @@ -115,7 +118,7 @@ policies: - path: ../lib/windows-device-health.policies.yml - path: ../lib/linux-device-health.policies.yml - name: macOS - Check if latest version - query: SELECT 1 FROM os_version WHERE major = '14' AND minor = '5'; + query: SELECT 1 FROM os_version WHERE (major = '14' AND minor = '6') OR major = '15'; critical: false description: Using an outdated macOS version risks exposure to security vulnerabilities and potential system instability. resolution: We will update your macOS to the latest version. @@ -129,12 +132,26 @@ policies: platform: darwin calendar_events_enabled: false - name: macOS - System maintenance complete - query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Titanosauria', 'Drew’s MacBook Pro','fleetwoodmike','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); + query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Drew’s MacBook Pro','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); critical: false description: Determines if the device has completed system maintenance. resolution: We will perform system maintenance on your device. platform: darwin calendar_events_enabled: true + - name: macOS - Upgrade Firefox + query: SELECT 1 FROM apps WHERE name = 'Firefox.app' AND version_compare(bundle_short_version, '130.0.1') >= 0; + critical: false + description: The host may have an outdated or non-existent version of Firefox, potentially risking security vulnerabilities or compatibility issues. + resolution: During maintenance, the Firefox app could be updated to the correct version or installed if it's missing. + platform: darwin + calendar_events_enabled: false + - name: macOS - Upgrade Slack + query: SELECT 1 FROM apps WHERE name = 'Slack.app' AND version_compare(bundle_short_version, '4.40.126') >= 0; + critical: false + description: The host may be running an outdated version of Slack, which could pose security vulnerabilities or compatibility issues. + resolution: The host's Slack application will likely be updated to a version that is greater than or equal to '4.40.126'. + platform: darwin + calendar_events_enabled: false queries: - path: ../lib/collect-failed-login-attempts.queries.yml - path: ../lib/collect-fleetd-information.yml @@ -143,3 +160,9 @@ queries: - path: ../lib/collect-software-permissions-system.queries.yml - path: ../lib/collect-software-permissions-user.queries.yml - path: ../lib/collect-crowdstrike-info.queries.yml +software: + app_store_apps: + - app_store_id: '803453959' # Slack Desktop + - app_store_id: '1333542190' # 1Password 7 Desktop + - app_store_id: '1477376905' # GitHub + - app_store_id: '1152747299' # Figma diff --git a/it-and-security/teams/workstations.yml b/it-and-security/teams/workstations.yml index 1e6981a0b3..0ad266f905 100644 --- a/it-and-security/teams/workstations.yml +++ b/it-and-security/teams/workstations.yml @@ -18,7 +18,6 @@ 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 @@ -44,8 +43,8 @@ controls: enable_end_user_authentication: true macos_setup_assistant: null macos_updates: - deadline: "2024-07-12" - minimum_version: "14.5" + deadline: "2024-08-23" + minimum_version: "14.6.1" windows_settings: custom_settings: null windows_updates: @@ -64,6 +63,13 @@ policies: - path: ../lib/linux-device-health.policies.yml - path: ../lib/macos-cis.policies.yml - path: ../lib/windows-cis.policies.yml + - name: macOS - Check if latest version + query: SELECT 1 FROM os_version WHERE (major = '14' AND minor = '6') OR major = '15'; + critical: false + description: Using an outdated macOS version risks exposure to security vulnerabilities and potential system instability. + resolution: We will update your macOS to the latest version. + platform: darwin + calendar_events_enabled: false queries: - path: ../lib/collect-failed-login-attempts.queries.yml - path: ../lib/collect-usb-devices.queries.yml @@ -76,7 +82,15 @@ queries: automations_enabled: false observer_can_run: true software: - - url: https://zoom.us/client/latest/Zoom.pkg?archType=arm64 - pre_install_query: - path: ../lib/macos-check-if-apple-silicon.queries.yml - self_service: true + packages: + - url: https://zoom.us/client/latest/Zoom.pkg?archType=arm64 + pre_install_query: + path: ../lib/macos-check-if-apple-silicon.queries.yml + self_service: true + - url: https://dl.google.com/chrome/mac/stable/accept_tos%3Dhttps%253A%252F%252Fwww.google.com%252Fintl%252Fen_ph%252Fchrome%252Fterms%252F%26_and_accept_tos%3Dhttps%253A%252F%252Fpolicies.google.com%252Fterms/googlechrome.pkg + self_service: true + app_store_apps: + - app_store_id: '803453959' # Slack Desktop + - app_store_id: '1333542190' # 1Password 7 Desktop + - app_store_id: '1477376905' # GitHub + - app_store_id: '1152747299' # Figma diff --git a/orbit/CHANGELOG.md b/orbit/CHANGELOG.md index 6596e4286f..fa9efe36ef 100644 --- a/orbit/CHANGELOG.md +++ b/orbit/CHANGELOG.md @@ -1,3 +1,29 @@ +## Orbit 1.33.0 (Sep 20, 2024) + +* Added support to run the configured uninstall script when installer's post-install script fails. + +* Updated Go to go1.23.1 + +## Orbit 1.32.0 (Aug 29, 2024) + +* Bumped macadmins extension to use SOFA feed sofafeed.macadmins.io + +* Fixed Fleet Desktop to refresh host status when the user clicks on "My Device" or "Self-service" dropdown option. + +* Updated go to go1.22.6 + +* Added ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. + +* Fixed a formatting error when an unrecognized error happens during BitLocker encryption. + +## Orbit 1.31.0 (Aug 19, 2024) + +* Fixed an issue that would display a disk encryption modal with MDM configured and FileVault enabled if the user hadn't escrowed the key in the past. + +## Orbit 1.30.0 (Aug 05, 2024) + +* Use Escrow Buddy to rotate FileVault keys on macOS + ## Orbit 1.29.0 (Jul 24, 2024) * Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. diff --git a/orbit/TUF.md b/orbit/TUF.md index 1671d78545..c022aa4a65 100644 --- a/orbit/TUF.md +++ b/orbit/TUF.md @@ -7,18 +7,20 @@ Following are the currently deployed versions of fleetd components on the `stabl | Component\OS | macOS | Linux | Windows | Linux (arm64) | |--------------|--------------|--------|---------|---------------| -| orbit | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| desktop | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| osqueryd | 5.12.1 | 5.12.1 | 5.12.1 | 5.12.1 | +| orbit | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 | +| desktop | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 | +| osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 | | nudge | 1.1.10.81462 | - | - | - | | swiftDialog | 2.1.0 | - | - | - | +| escrowBuddy | 1.0.0 | - | - | - | ## `edge` | Component\OS | macOS | Linux | Windows | Linux (arm64) | |--------------|--------|--------|---------|---------------| -| orbit | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| desktop | 1.29.0 | 1.29.0 | 1.29.0 | 1.29.0 | -| osqueryd | 5.12.2 | 5.12.2 | 5.12.2 | 5.12.1 | +| orbit | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 | +| desktop | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 | +| osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 | | nudge | - | - | - | - | | swiftDialog | - | - | - | - | +| escrowBuddy | - | - | - | - | diff --git a/orbit/changes/13157-fv-escrow b/orbit/changes/13157-fv-escrow deleted file mode 100644 index b4ff408b05..0000000000 --- a/orbit/changes/13157-fv-escrow +++ /dev/null @@ -1 +0,0 @@ -* Use Escrow Buddy to rotate FileVault keys on macOS diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index b423936061..89915c8f4a 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -1,6 +1,7 @@ package main import ( + "context" _ "embed" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "fyne.io/systray" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/orbit/pkg/go-paniclog" + "github.com/fleetdm/fleet/v4/orbit/pkg/migration" "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/orbit/pkg/token" "github.com/fleetdm/fleet/v4/orbit/pkg/update" @@ -59,6 +61,11 @@ func setupRunners() { } func main() { + // FIXME: we need to do a better job of graceful shutdown, releasing resources, stopping + // tickers, etc. (https://github.com/fleetdm/fleet/issues/21256) + // This context will be used as a general context to handle graceful shutdown in the future. + offlineWatcherCtx, cancelOfflineWatcherCtx := context.WithCancel(context.Background()) + // Orbits uses --version to get the fleet-desktop version. Logs do not need to be set up when running this. if len(os.Args) > 1 && os.Args[1] == "--version" { // Must work with update.GetVersion @@ -105,6 +112,15 @@ func main() { go setupRunners() var mdmMigrator useraction.MDMMigrator + // swiftDialogCh is a channel shared by the migrator and the offline watcher to + // coordinate the display of the dialog and ensure only one dialog is shown at a time. + var swiftDialogCh chan struct{} + var offlineWatcher useraction.MDMOfflineWatcher + + // This ticker is used for fetching the desktop summary. It is initialized here because it is + // stopped in `OnExit.` + const checkInterval = 5 * time.Minute + summaryTicker := time.NewTicker(checkInterval) onReady := func() { log.Info().Msg("ready") @@ -157,6 +173,7 @@ func main() { if err != nil { log.Fatal().Err(err).Msg("unable to initialize request client") } + client.WithInvalidTokenRetry(func() string { log.Debug().Msg("refetching token from disk for API retry") newToken, err := tokenReader.Read() @@ -168,13 +185,6 @@ func main() { return newToken }) - refetchToken := func() { - if _, err := tokenReader.Read(); err != nil { - log.Error().Err(err).Msg("refetch token") - } - log.Debug().Msg("successfully refetched the token from disk") - } - disableTray := func() { log.Debug().Msg("disabling tray items") myDeviceItem.SetTitle("Connecting...") @@ -186,6 +196,44 @@ func main() { migrateMDMItem.Hide() } + reportError := func(err error, info map[string]any) { + if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) { + log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled") + return + } + + fleetdErr := fleet.FleetdError{ + ErrorSource: "fleet-desktop", + ErrorSourceVersion: version, + ErrorTimestamp: time.Now(), + ErrorMessage: err.Error(), + ErrorAdditionalInfo: info, + } + + if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil { + log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server") + } + } + + if runtime.GOOS == "darwin" { + m, s, o, err := mdmMigrationSetup(offlineWatcherCtx, tufUpdateRoot, fleetURL, client, &tokenReader) + if err != nil { + go reportError(err, nil) + log.Error().Err(err).Msg("setting up MDM migration resources") + } + + mdmMigrator = m + swiftDialogCh = s + offlineWatcher = o + } + + refetchToken := func() { + if _, err := tokenReader.Read(); err != nil { + log.Error().Err(err).Msg("refetch token") + } + log.Debug().Msg("successfully refetched the token from disk") + } + // checkToken performs API test calls to enable the "My device" item as // soon as the device auth token is registered by Fleet. checkToken := func() <-chan interface{} { @@ -246,50 +294,15 @@ func main() { } }() - if runtime.GOOS == "darwin" { - _, swiftDialogPath, _ := update.LocalTargetPaths( - tufUpdateRoot, - "swiftDialog", - update.SwiftDialogMacOSTarget, - ) - mdmMigrator = useraction.NewMDMMigrator( - swiftDialogPath, - 15*time.Minute, - &mdmMigrationHandler{ - client: client, - tokenReader: &tokenReader, - }, - ) - } - - reportError := func(err error, info map[string]any) { - if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) { - log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled") - return - } - - fleetdErr := fleet.FleetdError{ - ErrorSource: "fleet-desktop", - ErrorSourceVersion: version, - ErrorTimestamp: time.Now(), - ErrorMessage: err.Error(), - ErrorAdditionalInfo: info, - } - - if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil { - log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server") - } - } - // poll the server to check the policy status of the host and update the // tray icon accordingly go func() { <-deviceEnabledChan - tic := time.NewTicker(5 * time.Minute) - defer tic.Stop() for { - <-tic.C + <-summaryTicker.C + // Reset the ticker to the intended interval, in case we reset it to 1ms + summaryTicker.Reset(checkInterval) sum, err := client.DesktopSummary(tokenReader.GetCached()) switch { case err == nil: @@ -306,42 +319,22 @@ func main() { continue } - // Check for null for backward compatibility with an old Fleet server - if sum.SelfService != nil && !*sum.SelfService { - selfServiceItem.Disable() - selfServiceItem.Hide() - } else { - selfServiceItem.Enable() - selfServiceItem.Show() - } - - failingPolicies := 0 - if sum.FailingPolicies != nil { - failingPolicies = int(*sum.FailingPolicies) - } - - if failingPolicies > 0 { - if runtime.GOOS == "windows" { - // Windows (or maybe just the systray library?) doesn't support color emoji - // in the system tray menu, so we use text as an alternative. - if failingPolicies == 1 { - myDeviceItem.SetTitle("My device (1 issue)") - } else { - myDeviceItem.SetTitle(fmt.Sprintf("My device (%d issues)", failingPolicies)) - } - } else { - myDeviceItem.SetTitle(fmt.Sprintf("🔴 My device (%d)", failingPolicies)) - } - } else { - if runtime.GOOS == "windows" { - myDeviceItem.SetTitle("My device") - } else { - myDeviceItem.SetTitle("🟢 My device") - } - } + refreshMenuItems(sum.DesktopSummary, selfServiceItem, myDeviceItem) myDeviceItem.Enable() - shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile + // Check our file to see if we should migrate + var migrationType string + if runtime.GOOS == "darwin" { + migrationType, err = mdmMigrator.MigrationInProgress() + if err != nil { + go reportError(err, nil) + log.Error().Err(err).Msg("checking if MDM migration is in progress") + } + } + + migrationInProgress := migrationType != "" + + shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile || migrationInProgress if runtime.GOOS == "darwin" && shouldRunMigrator && mdmMigrator.CanRun() { enrolled, enrollURL, err := profiles.IsEnrolledInMDM() @@ -376,18 +369,28 @@ func main() { }) // enable tray items - migrateMDMItem.Enable() - migrateMDMItem.Show() + if migrationType != constant.MDMMigrationTypeADE { + migrateMDMItem.Enable() + migrateMDMItem.Show() + } // if the device is unmanaged or we're in force mode and the device needs // migration, enable aggressive mode. - if isUnmanaged || forceModeEnabled { + if isUnmanaged || forceModeEnabled || migrationInProgress { log.Info().Msg("MDM device is unmanaged or force mode enabled, automatically showing dialog") if err := mdmMigrator.ShowInterval(); err != nil { go reportError(err, nil) log.Error().Err(err).Msg("showing MDM migration dialog at interval") } } + } else { + // we're done with the migration, so mark it as complete. + if err := mdmMigrator.MarkMigrationCompleted(); err != nil { + go reportError(err, nil) + log.Error().Err(err).Msg("failed to mark MDM migration as completed") + } + migrateMDMItem.Disable() + migrateMDMItem.Hide() } } else { migrateMDMItem.Disable() @@ -404,6 +407,8 @@ func main() { if err := open.Browser(openURL); err != nil { log.Error().Err(err).Str("url", openURL).Msg("open browser my device") } + // Also refresh the device status by forcing the polling ticker to fire + summaryTicker.Reset(1 * time.Millisecond) case <-transparencyItem.ClickedCh: openURL := client.BrowserTransparencyURL(tokenReader.GetCached()) if err := open.Browser(openURL); err != nil { @@ -414,7 +419,13 @@ func main() { if err := open.Browser(openURL); err != nil { log.Error().Err(err).Str("url", openURL).Msg("open browser self-service") } + // Also refresh the device status by forcing the polling ticker to fire + summaryTicker.Reset(1 * time.Millisecond) case <-migrateMDMItem.ClickedCh: + if offline := offlineWatcher.ShowIfOffline(offlineWatcherCtx); offline { + continue + } + if err := mdmMigrator.Show(); err != nil { go reportError(err, nil) log.Error().Err(err).Msg("showing MDM migration dialog on user action") @@ -423,16 +434,60 @@ func main() { } }() } + + // FIXME: it doesn't look like this is actually triggering, at least when desktop gets + // killed (https://github.com/fleetdm/fleet/issues/21256) onExit := func() { + log.Info().Msg("exit") if mdmMigrator != nil { mdmMigrator.Exit() } - log.Info().Msg("exit") + if swiftDialogCh != nil { + close(swiftDialogCh) + } + summaryTicker.Stop() + cancelOfflineWatcherCtx() } systray.Run(onReady, onExit) } +func refreshMenuItems(sum fleet.DesktopSummary, selfServiceItem *systray.MenuItem, myDeviceItem *systray.MenuItem) { + // Check for null for backward compatibility with an old Fleet server + if sum.SelfService != nil && !*sum.SelfService { + selfServiceItem.Disable() + selfServiceItem.Hide() + } else { + selfServiceItem.Enable() + selfServiceItem.Show() + } + + failingPolicies := 0 + if sum.FailingPolicies != nil { + failingPolicies = int(*sum.FailingPolicies) + } + + if failingPolicies > 0 { + if runtime.GOOS == "windows" { + // Windows (or maybe just the systray library?) doesn't support color emoji + // in the system tray menu, so we use text as an alternative. + if failingPolicies == 1 { + myDeviceItem.SetTitle("My device (1 issue)") + } else { + myDeviceItem.SetTitle(fmt.Sprintf("My device (%d issues)", failingPolicies)) + } + } else { + myDeviceItem.SetTitle(fmt.Sprintf("🔴 My device (%d)", failingPolicies)) + } + } else { + if runtime.GOOS == "windows" { + myDeviceItem.SetTitle("My device") + } else { + myDeviceItem.SetTitle("🟢 My device") + } + } +} + type mdmMigrationHandler struct { client *service.DeviceClient tokenReader *token.Reader @@ -563,3 +618,36 @@ func logDir() (string, error) { return dir, nil } + +func mdmMigrationSetup(ctx context.Context, tufUpdateRoot, fleetURL string, client *service.DeviceClient, tokenReader *token.Reader) (useraction.MDMMigrator, chan struct{}, useraction.MDMOfflineWatcher, error) { + dir, err := migration.Dir() + if err != nil { + return nil, nil, nil, err + } + + mrw := migration.NewReadWriter(dir, constant.MigrationFileName) + + // we use channel buffer size of 1 to allow one dialog at a time with non-blocking sends. + swiftDialogCh := make(chan struct{}, 1) + + _, swiftDialogPath, _ := update.LocalTargetPaths( + tufUpdateRoot, + "swiftDialog", + update.SwiftDialogMacOSTarget, + ) + mdmMigrator := useraction.NewMDMMigrator( + swiftDialogPath, + 15*time.Minute, + &mdmMigrationHandler{ + client: client, + tokenReader: tokenReader, + }, + mrw, + fleetURL, + swiftDialogCh, + ) + + offlineWatcher := useraction.StartMDMMigrationOfflineWatcher(ctx, client, swiftDialogPath, swiftDialogCh, migration.FileWatcher(mrw)) + + return mdmMigrator, swiftDialogCh, offlineWatcher, nil +} diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 40d1d5283f..334d69be85 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -851,7 +851,7 @@ func main() { // create the notifications middleware that wraps the orbit client // (must be shared by all runners that use a ConfigFetcher). const ( - renewEnrollmentProfileCommandFrequency = time.Hour + renewEnrollmentProfileCommandFrequency = 3 * time.Minute windowsMDMEnrollmentCommandFrequency = time.Hour windowsMDMBitlockerCommandFrequency = time.Hour ) @@ -864,8 +864,7 @@ func main() { switch runtime.GOOS { case "darwin": orbitClient.RegisterConfigReceiver(update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware( - orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL, - )) + orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL)) const nudgeLaunchInterval = 30 * time.Minute orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{ UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval, @@ -1224,7 +1223,12 @@ func main() { if orbitClient.GetServerCapabilities().Has(fleet.CapabilityEscrowBuddy) { orbitClient.RegisterConfigReceiver(update.NewEscrowBuddyRunner(updateRunner, 5*time.Minute)) } else { - orbitClient.RegisterConfigReceiver(update.ApplyDiskEncryptionRunnerMiddleware()) + orbitClient.RegisterConfigReceiver( + update.ApplyDiskEncryptionRunnerMiddleware( + orbitClient.GetServerCapabilities, + orbitClient.TriggerOrbitRestart, + ), + ) } } diff --git a/orbit/pkg/bitlocker/bitlocker_management_windows.go b/orbit/pkg/bitlocker/bitlocker_management_windows.go index 79a5791a49..4fe472d19f 100644 --- a/orbit/pkg/bitlocker/bitlocker_management_windows.go +++ b/orbit/pkg/bitlocker/bitlocker_management_windows.go @@ -85,7 +85,7 @@ func encryptErrHandler(val int32) error { case ErrorCodeProtectorExists: msg = "key protector cannot be added; only one key protector of this type is allowed for this drive" default: - msg = "error code returned during encryption: %d" + msg = fmt.Sprintf("error code returned during encryption: %d", val) } return &EncryptionError{msg, val} diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 8a11160e5b..8f8e7e91e5 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -55,4 +55,17 @@ const ( // ServerOverridesFileName is the name of the file in the root directory // that specifies the override configuration fetched from the server. ServerOverridesFileName = "server-overrides.json" + // MigrationFileName is the name of the file used by fleetd to determine if the host is + // partially through an MDM migration. + MigrationFileName = "mdm_migration.txt" + // MDMMigrationTypeManual indicates that the MDM migration is for a manually enrolled host. + MDMMigrationTypeManual = "manual" + // MDMMigrationTypeADE indicates that the MDM migration is for an ADE enrolled host. + MDMMigrationTypeADE = "ade" + // MDMMigrationTypePreSonoma indicates that the MDM migration is for a host on a macOS version < 14. + MDMMigrationTypePreSonoma = "pre-sonoma" + // MDMMigrationOfflineWatcherInterval is the interval at which the offline watcher checks for + // the presence of the migration file. + MDMMigrationOfflineWatcherInterval = 3 * time.Minute + SonomaMajorVersion = 14 ) diff --git a/orbit/pkg/installer/installer.go b/orbit/pkg/installer/installer.go index 3f8f31cb46..c8fac3fcca 100644 --- a/orbit/pkg/installer/installer.go +++ b/orbit/pkg/installer/installer.go @@ -242,13 +242,24 @@ func (r *Runner) installSoftware(ctx context.Context, installID string) (*fleet. log.Info().Msgf("installation of %s failed, attempting rollback. Exit code: %d, error: %s", installerPath, postExitCode, postErr) ext := filepath.Ext(installerPath) ext = strings.TrimPrefix(ext, ".") - uninstallScript := file.GetRemoveScript(ext) - uninstallOutput, uninstallExitCode, uninstallErr := r.runInstallerScript(ctx, uninstallScript, installerPath, "rollback-script") + uninstallScript := installer.UninstallScript + var builder strings.Builder + builder.WriteString(*payload.PostInstallScriptOutput) + builder.WriteString("\nAttempting rollback by running uninstall script...\n") + if uninstallScript == "" { + // The Fleet server is < v4.57.0, so we need to use the old method. + // If all customers have updated to v4.57.0 or later, we can remove this method. + uninstallScript = file.GetRemoveScript(ext) + } + uninstallOutput, uninstallExitCode, uninstallErr := r.runInstallerScript(ctx, uninstallScript, installerPath, + "rollback-script"+scriptExtension) log.Info().Msgf( "rollback staus: exit code: %d, error: %s, output: %s", uninstallExitCode, uninstallErr, uninstallOutput, ) - + builder.WriteString(fmt.Sprintf("Uninstall script exit code: %d\n", uninstallExitCode)) + builder.WriteString(uninstallOutput) + payload.PostInstallScriptOutput = ptr.String(builder.String()) return payload, uninstallErr } } diff --git a/orbit/pkg/installer/installer_test.go b/orbit/pkg/installer/installer_test.go index 106a0b59c9..76d6173071 100644 --- a/orbit/pkg/installer/installer_test.go +++ b/orbit/pkg/installer/installer_test.go @@ -8,11 +8,13 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "testing" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" osquery_gen "github.com/osquery/osquery-go/gen/osquery" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -360,6 +362,10 @@ func TestInstallerRun(t *testing.T) { if len(executedScripts) == 2 { return execOutput, 1, &exec.ExitError{} } + // good exit on rollback uninstall script + if len(executedScripts) == 3 { + return []byte("all good"), 0, nil + } return execOutput, execExitCode, execErr } @@ -374,7 +380,9 @@ func TestInstallerRun(t *testing.T) { require.Equal(t, 0, *savedInstallerResult.InstallScriptExitCode) require.Equal(t, string(execOutput), *savedInstallerResult.InstallScriptOutput) require.Equal(t, 1, *savedInstallerResult.PostInstallScriptExitCode) - require.Equal(t, string(execOutput), *savedInstallerResult.PostInstallScriptOutput) + require.NotNil(t, savedInstallerResult.PostInstallScriptOutput) + numPostInstallMatches := strings.Count(*savedInstallerResult.PostInstallScriptOutput, string(execOutput)) + assert.Equal(t, 1, numPostInstallMatches, *savedInstallerResult.PostInstallScriptOutput) }) t.Run("failed rollback script", func(t *testing.T) { @@ -402,7 +410,8 @@ func TestInstallerRun(t *testing.T) { require.Equal(t, 0, *savedInstallerResult.InstallScriptExitCode) require.Equal(t, string(execOutput), *savedInstallerResult.InstallScriptOutput) require.Equal(t, 1, *savedInstallerResult.PostInstallScriptExitCode) - require.Equal(t, string(execOutput), *savedInstallerResult.PostInstallScriptOutput) + numPostInstallMatches := strings.Count(*savedInstallerResult.PostInstallScriptOutput, string(execOutput)) + assert.Equal(t, 2, numPostInstallMatches) }) } diff --git a/orbit/pkg/migration/readwriter.go b/orbit/pkg/migration/readwriter.go new file mode 100644 index 0000000000..08c7d36537 --- /dev/null +++ b/orbit/pkg/migration/readwriter.go @@ -0,0 +1,157 @@ +package migration + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" +) + +type ReadWriter struct { + Path string + FileName string +} + +func NewReadWriter(path, filename string) *ReadWriter { + return &ReadWriter{ + Path: path, + FileName: filepath.Join(path, filename), + } +} + +// SetMigrationFile sets `typ` in the file used to track MDM migration type. This overwrites the +// file if it exists. +func (rw *ReadWriter) SetMigrationFile(typ string) error { + _, err := rw.read() + switch { + case err == nil: + // ensure the file is readable by other processes + if err := rw.setChmod(); err != nil { + return fmt.Errorf("loading migration file, chmod %q: %w", rw.Path, err) + } + case errors.Is(err, os.ErrNotExist): + if err := os.MkdirAll(rw.Path, constant.DefaultDirMode); err != nil { + return fmt.Errorf("creating directory for migration file: %w", err) + } + if err := os.WriteFile(rw.FileName, []byte(typ), constant.DefaultWorldReadableFileMode); err != nil { + return fmt.Errorf("writing migration file: %w", err) + } + + default: + return fmt.Errorf("load migration file %q: %w", rw.Path, err) + } + return nil +} + +// RemoveFile removes the file used for tracking the MDM migration type. +func (rw *ReadWriter) RemoveFile() error { + if err := os.Remove(rw.FileName); err != nil { + if errors.Is(err, os.ErrNotExist) { + // that's ok, noop + return nil + } + + return fmt.Errorf("removing migration file: %w", err) + } + + return nil +} + +// GetMigrationType returns the contents of the MDM migration file. The contents say what type of +// migration it is. +func (rw *ReadWriter) GetMigrationType() (string, error) { + data, err := rw.read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + + return "", err + } + + return data, nil +} + +// FileExists returns whether or not the MDM migration file exists on this host. +func (rw *ReadWriter) FileExists() (bool, error) { + _, err := os.Stat(rw.FileName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, err + } + + return true, nil +} + +// DirExists returns whether or not the directory where the MDM migration file is stored exists. +func (rw *ReadWriter) DirExists() (bool, error) { + _, err := os.Stat(rw.FileName) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, err + } + + return true, nil +} + +func (rw *ReadWriter) read() (string, error) { + data, err := os.ReadFile(rw.FileName) + if err != nil { + return "", err + } + + return string(data), nil +} + +func (rw *ReadWriter) setChmod() error { + return os.Chmod(rw.FileName, constant.DefaultWorldReadableFileMode) +} + +func (rw *ReadWriter) NewFileWatcher() FileWatcher { + return &fileWatcher{rw: rw} +} + +type FileWatcher interface { + GetMigrationType() (string, error) + FileExists() (bool, error) + DirExists() (bool, error) +} + +type fileWatcher struct { + rw *ReadWriter +} + +// GetMigrationType returns the contents of the MDM migration file which indicate what type of +// migration it is. +func (r *fileWatcher) GetMigrationType() (string, error) { + return r.rw.GetMigrationType() +} + +// FileExists returns whether or not the MDM migration file exists on this host. +func (r *fileWatcher) FileExists() (bool, error) { + return r.rw.FileExists() +} + +// DirExists returns whether or not the directory where the MDM migration file is stored exists. +func (r *fileWatcher) DirExists() (bool, error) { + return r.rw.DirExists() +} + +// Dir returns the path to the directory where the MDM migration file is stored. This path should be +// ~/Library/Caches/com.fleetdm.orbit +func Dir() (string, error) { + homedir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user's home directory: %w", err) + } + + return filepath.Join(homedir, "Library/Caches/com.fleetdm.orbit"), nil +} diff --git a/orbit/pkg/profiles/profiles_darwin.go b/orbit/pkg/profiles/profiles_darwin.go index 93d197a587..8f4dd296f7 100644 --- a/orbit/pkg/profiles/profiles_darwin.go +++ b/orbit/pkg/profiles/profiles_darwin.go @@ -127,6 +127,37 @@ func IsEnrolledInMDM() (bool, string, error) { return true, enrollmentURL, nil } +func IsManuallyEnrolledInMDM() (bool, error) { + out, err := getMDMInfoFromProfilesCmd() + if err != nil { + return false, fmt.Errorf("calling /usr/bin/profiles: %w", err) + } + + // The output of the command is in the form: + // + // ``` + // Enrolled via DEP: No + // MDM enrollment: Yes (User Approved) + // MDM server: https://test.example.com/mdm/apple/mdm + // ``` + // + // If the host is not enrolled into an MDM, the last line is ommitted, + // so we need to check that: + // + // 1. We've got three rows + // 2. Whether the first line contains "Yes" or "No" + lines := bytes.Split(bytes.TrimSpace(out), []byte("\n")) + if len(lines) < 3 { + return false, nil + } + + if strings.Contains(string(lines[0]), "Yes") { + return false, nil + } + + return true, nil +} + // getMDMInfoFromProfilesCmd is declared as a variable so it can be overwritten by tests. var getMDMInfoFromProfilesCmd = func() ([]byte, error) { cmd := exec.Command("/usr/bin/profiles", "status", "-type", "enrollment") diff --git a/orbit/pkg/update/disk_encryption.go b/orbit/pkg/update/disk_encryption.go index ae09f386d6..1f6497265d 100644 --- a/orbit/pkg/update/disk_encryption.go +++ b/orbit/pkg/update/disk_encryption.go @@ -1,6 +1,7 @@ package update import ( + "errors" "sync/atomic" "github.com/fleetdm/fleet/v4/orbit/pkg/useraction" @@ -11,16 +12,37 @@ import ( const maxRetries = 2 type DiskEncryptionRunner struct { - isRunning atomic.Bool + isRunning atomic.Bool + capabilitiesFetcher func() fleet.CapabilityMap + triggerOrbitRestart func(reason string) } -func ApplyDiskEncryptionRunnerMiddleware() fleet.OrbitConfigReceiver { - return &DiskEncryptionRunner{} +func ApplyDiskEncryptionRunnerMiddleware( + capabilitiesFetcher func() fleet.CapabilityMap, + triggerOrbitRestart func(reason string), +) fleet.OrbitConfigReceiver { + return &DiskEncryptionRunner{ + capabilitiesFetcher: capabilitiesFetcher, + triggerOrbitRestart: triggerOrbitRestart, + } } func (d *DiskEncryptionRunner) Run(cfg *fleet.OrbitConfig) error { log.Debug().Msgf("running disk encryption fetcher middleware, notification: %v, isIdle: %v", cfg.Notifications.RotateDiskEncryptionKey, d.isRunning.Load()) + if d.capabilitiesFetcher == nil { + return errors.New("disk encryption runner needs a capabilitites fetcher configured") + } + + if d.triggerOrbitRestart == nil { + return errors.New("disk encryption runner needs a function to trigger orbit restarts configured") + } + + if d.capabilitiesFetcher().Has(fleet.CapabilityEscrowBuddy) { + d.triggerOrbitRestart("server has Escrow Buddy capability but old disk encryption fetcher was running") + return nil + } + if cfg.Notifications.RotateDiskEncryptionKey && !d.isRunning.Swap(true) { go func() { defer d.isRunning.Store(false) diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index 3d8b9bb033..fa5b6b993c 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -66,7 +66,7 @@ func (h *renewEnrollmentProfileConfigReceiver) Run(config *fleet.OrbitConfig) er // enrolled (after the user's manual steps, and osquery reporting the // updated mdm enrollment). // See https://github.com/fleetdm/fleet/pull/9409#discussion_r1084382455 - if time.Since(h.lastRun) > h.Frequency { + if time.Since(h.lastRun) >= h.Frequency { // we perform this check locally on the client too to avoid showing the // dialog if the client is enrolled to an MDM server. enrollFn := h.checkEnrollmentFn diff --git a/orbit/pkg/update/swift_dialog.go b/orbit/pkg/update/swift_dialog.go index eebd68477b..bdbfde5e3e 100644 --- a/orbit/pkg/update/swift_dialog.go +++ b/orbit/pkg/update/swift_dialog.go @@ -34,7 +34,10 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error { return nil } + // TODO: we probably want to ensure that swiftDialog is always installed if we're going to be + // using it offline. if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile { + log.Debug().Msg("got false needs migration and false renew enrollment") return nil } diff --git a/orbit/pkg/useraction/mdm_migration.go b/orbit/pkg/useraction/mdm_migration.go index 27480b6ab6..228a396664 100644 --- a/orbit/pkg/useraction/mdm_migration.go +++ b/orbit/pkg/useraction/mdm_migration.go @@ -1,6 +1,10 @@ package useraction -import "github.com/fleetdm/fleet/v4/server/fleet" +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/fleet" +) // MDMMigrator represents the minimum set of methods a migration must implement // in order to be used by Fleet Desktop. @@ -19,6 +23,12 @@ type MDMMigrator interface { ShowInterval() error // Exit tries to stop any processes started by the migrator. Exit() + // MigrationInProgress checks if the MDM migration is still in progress (i.e. the host is not + // yet fully enrolled in Fleet MDM). It returns the type of migration that is in progress, if any. + MigrationInProgress() (string, error) + // MarkMigrationCompleted marks the migration as completed. This is currently done by removing + // the migration file. + MarkMigrationCompleted() error } // MDMMigratorProps are props required to display the dialog. It's akin to the @@ -26,6 +36,8 @@ type MDMMigrator interface { type MDMMigratorProps struct { OrgInfo fleet.DesktopOrgInfo IsUnmanaged bool + // DisableTakeover is used to disable the blur and always on top features of the dialog. + DisableTakeover bool } // MDMMigratorHandler handles remote actions/callbacks that the migrator calls. @@ -33,3 +45,7 @@ type MDMMigratorHandler interface { NotifyRemote() error ShowInstructions() error } + +type MDMOfflineWatcher interface { + ShowIfOffline(ctx context.Context) bool +} diff --git a/orbit/pkg/useraction/mdm_migration_darwin.go b/orbit/pkg/useraction/mdm_migration_darwin.go index ba4c574d6e..e4daeab997 100644 --- a/orbit/pkg/useraction/mdm_migration_darwin.go +++ b/orbit/pkg/useraction/mdm_migration_darwin.go @@ -4,6 +4,7 @@ package useraction import ( "bytes" + "context" "errors" "fmt" "os" @@ -14,9 +15,13 @@ import ( "text/template" "time" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" + "github.com/fleetdm/fleet/v4/orbit/pkg/migration" + "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/retry" + "github.com/fleetdm/fleet/v4/server/service" "github.com/rs/zerolog/log" ) @@ -56,15 +61,23 @@ var mdmMigrationTemplatePreSonoma = template.Must(template.New("mdmMigrationTemp Select **Start** and look for this notification in your notification center:` + "\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-screenshot-notification-2048x480.png)\n\n" + - "After you start, this window will popup every 15-20 minutes until you finish.", + "After you start, this window will popup every 3 minutes until you finish.", )) -var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(` +var mdmManualMigrationTemplate = template.Must(template.New("").Parse(` +## Migrate to Fleet + +Select **Start** and My device page will appear soon:` + + "\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-manual-migration-1024x500.png)\n\n" + + "After you start, this dialog will popup every 3 minutes until you finish.", +)) + +var mdmADEMigrationTemplate = template.Must(template.New("").Parse(` ## Migrate to Fleet Select **Start** and Remote Management window will appear soon:` + - "\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-sonoma-1500x938.png)\n\n" + - "After you start, this window will popup every 15-20 minutes until you finish.", + "\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-ade-migration-1024x500.png)\n\n" + + "After you start, **Remote Management** will popup every minute until you finish.", )) var errorTemplate = template.Must(template.New("").Parse(` @@ -73,6 +86,14 @@ var errorTemplate = template.Must(template.New("").Parse(` Please contact your IT admin [here]({{ .ContactURL }}). `)) +var unenrollBody = "## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...\n\n%s" + +var mdmMigrationTemplateOffline = template.Must(template.New("").Parse(` +## Migrate to Fleet + +🛜🚫 No internet connection. Please connect to internet to continue.`, +)) + // baseDialog implements the basic building blocks to render dialogs using // swiftDialog. type baseDialog struct { @@ -110,11 +131,9 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err exitCodeCh := make(chan swiftDialogExitCode, 1) errCh := make(chan error, 1) go func() { - // all dialogs should always be blurred and on top + // all dialogs should always be centered flags = append( flags, - "--blurscreen", - "--ontop", "--messageposition", "center", ) cmd := exec.Command(b.path, flags...) //nolint:gosec @@ -166,14 +185,18 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err } // NewMDMMigrator creates a new swiftDialogMDMMigrator with the right internal state. -func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator { +func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator { + if cap(showCh) != 1 { + log.Fatal().Msg("swift dialog channel must have a buffer size of 1") + } return &swiftDialogMDMMigrator{ handler: handler, baseDialog: newBaseDialog(path), frequency: frequency, unenrollmentRetryInterval: defaultUnenrollmentRetryInterval, - // set a buffer size of 1 to allow one Show without blocking - showCh: make(chan struct{}, 1), + mrw: mrw, + fleetURL: fleetURL, + showCh: showCh, } } @@ -189,7 +212,8 @@ type swiftDialogMDMMigrator struct { // lastShown lastShown time.Time lastShownMu sync.RWMutex - showCh chan struct{} + // showCh is shared with the offline watcher and used to ensure only one dialog is open at a time + showCh chan struct{} // testEnrollmentCheckFileFn is used in tests to mock the call to verify // the enrollment status of the host @@ -198,6 +222,8 @@ type swiftDialogMDMMigrator struct { // the enrollment status of the host testEnrollmentCheckStatusFn func() (bool, string, error) unenrollmentRetryInterval time.Duration + mrw *migration.ReadWriter + fleetURL string } /** @@ -248,12 +274,23 @@ func (m *swiftDialogMDMMigrator) render(message string, flags ...string) (chan s return m.baseDialog.render(flags...) } -func (m *swiftDialogMDMMigrator) renderLoadingSpinner() (chan swiftDialogExitCode, chan error) { - return m.render("## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...", +func (m *swiftDialogMDMMigrator) renderLoadingSpinner(preSonoma, isManual bool) (chan swiftDialogExitCode, chan error) { + var body string + switch true { + case preSonoma: + body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png)") + case isManual: + body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-manual-migration-1024x500.png)") + default: + // ADE migration, macOS > 14 + body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-ade-migration-1024x500.png)") + } + + return m.render(body, "--button1text", "Start", "--button1disabled", "--quitkey", "x", - "--height", "220", + "--height", "669", ) } @@ -275,7 +312,7 @@ func (m *swiftDialogMDMMigrator) renderError() (chan swiftDialogExitCode, chan e // waitForUnenrollment waits 90 seconds (value determined by product) for the // device to unenroll from the current MDM solution. If the device doesn't // unenroll, an error is returned. -func (m *swiftDialogMDMMigrator) waitForUnenrollment() error { +func (m *swiftDialogMDMMigrator) waitForUnenrollment(isADEMigration bool) error { maxRetries := int(mdmUnenrollmentTotalWaitTime.Seconds() / m.unenrollmentRetryInterval.Seconds()) checkFileFn := m.testEnrollmentCheckFileFn if checkFileFn == nil { @@ -292,14 +329,16 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error { return retry.Do(func() error { var unenrolled bool - fileExists, fileErr := checkFileFn() - if fileErr != nil { - log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal") - } else if fileExists { - log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found") - } else { - log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found") - unenrolled = true + if isADEMigration { + fileExists, fileErr := checkFileFn() + if fileErr != nil { + log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal") + } else if fileExists { + log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found") + } else { + log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found") + unenrolled = true + } } statusEnrolled, serverURL, statusErr := checkStatusFn() @@ -326,7 +365,33 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error { } func (m *swiftDialogMDMMigrator) renderMigration() error { - message, flags, err := m.getMessageAndFlags() + log.Debug().Msg("checking current enrollment status") + isCurrentlyManuallyEnrolled, err := profiles.IsManuallyEnrolledInMDM() + if err != nil { + return err + } + + // Check what kind of migration was in progress, if any. + previousMigrationType, err := m.mrw.GetMigrationType() + if err != nil { + log.Error().Err(err).Msg("getting migration type") + return fmt.Errorf("getting migration type: %w", err) + } + + isManualMigration := isCurrentlyManuallyEnrolled || previousMigrationType == constant.MDMMigrationTypeManual + isADEMigration := previousMigrationType == constant.MDMMigrationTypeADE + + log.Debug().Bool("isManualMigration", isManualMigration).Bool("isADEMigration", isADEMigration).Bool("isCurrentlyManuallyEnrolled", isCurrentlyManuallyEnrolled).Str("previousMigrationType", previousMigrationType).Msg("props after assigning") + + vers, err := m.getMacOSMajorVersion() + if err != nil { + // log error for debugging and continue with default template + log.Error().Err(err).Msg("getting macOS major version failed: using default migration template") + } + + isPreSonoma := vers < constant.SonomaMajorVersion + + message, flags, err := m.getMessageAndFlags(vers, isManualMigration) if err != nil { return fmt.Errorf("getting mdm migrator message: %w", err) } @@ -342,9 +407,24 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { return nil } + if previousMigrationType == constant.MDMMigrationTypeADE { + // Do nothing; the Remote Management modal will be launched by Orbit every minute. + return nil + } + + if previousMigrationType == constant.MDMMigrationTypeManual || previousMigrationType == constant.MDMMigrationTypePreSonoma { + // Launch the "My device" page. + log.Info().Msg("showing instructions") + + if err := m.handler.ShowInstructions(); err != nil { + return err + } + return nil + } + if !m.props.IsUnmanaged { // show the loading spinner - m.renderLoadingSpinner() + m.renderLoadingSpinner(isPreSonoma, isCurrentlyManuallyEnrolled) // send the API call if notifyErr := m.handler.NotifyRemote(); notifyErr != nil { @@ -361,7 +441,7 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { } log.Info().Msg("webhook sent, checking for unenrollment") - if err := m.waitForUnenrollment(); err != nil { + if err := m.waitForUnenrollment(isADEMigration); err != nil { m.baseDialog.Exit() errDialogExitChan, errDialogErrChan := m.renderError() select { @@ -374,6 +454,35 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { } } + switch { + case isPreSonoma: + if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypePreSonoma); err != nil { + log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file") + } + + log.Info().Msg("showing instructions after pre-sonoma unenrollment") + if err := m.handler.ShowInstructions(); err != nil { + return err + } + + case isManualMigration: + if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeManual); err != nil { + log.Error().Str("migration_type", constant.MDMMigrationTypeManual).Err(err).Msg("set migration file") + } + + log.Info().Msg("showing instructions after manual unenrollment") + if err := m.handler.ShowInstructions(); err != nil { + return err + } + + m.frequency = 3 * time.Minute + + default: + if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeADE); err != nil { + log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file") + } + } + // close the spinner // TODO: maybe it's better to use // https://github.com/bartreardon/swiftDialog/wiki/Updating-Dialog-with-new-content @@ -381,10 +490,6 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { m.baseDialog.Exit() } - log.Info().Msg("showing instructions") - if err := m.handler.ShowInstructions(); err != nil { - return err - } } return nil @@ -435,16 +540,14 @@ func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) { m.props = props } -func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, error) { - vers, err := m.getMacOSMajorVersion() - if err != nil { - // log error for debugging and continue with default template - log.Error().Err(err).Msg("getting macOS major version failed: using default migration template") +func (m *swiftDialogMDMMigrator) getMessageAndFlags(version int, isManualMigration bool) (*bytes.Buffer, []string, error) { + tmpl := mdmADEMigrationTemplate + if isManualMigration { + tmpl = mdmManualMigrationTemplate } - tmpl := mdmMigrationTemplate height := "669" - if vers != 0 && vers < 14 { + if version != 0 && version < constant.SonomaMajorVersion { height = "440" tmpl = mdmMigrationTemplatePreSonoma } @@ -454,7 +557,7 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, &message, m.props, ); err != nil { - return nil, nil, fmt.Errorf("executing migrqation template: %w", err) + return nil, nil, fmt.Errorf("executing migration template: %w", err) } flags := []string{ @@ -465,6 +568,13 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, "--height", height, } + if !m.props.DisableTakeover { + flags = append(flags, + "--blurscreen", + "--ontop", + ) + } + if m.props.OrgInfo.ContactURL != "" { flags = append(flags, // info button @@ -502,3 +612,236 @@ func (m *swiftDialogMDMMigrator) getMacOSMajorVersion() (int, error) { } return major, nil } + +func (m *swiftDialogMDMMigrator) MigrationInProgress() (string, error) { + return m.mrw.GetMigrationType() +} + +func (m *swiftDialogMDMMigrator) MarkMigrationCompleted() error { + // Reset this to the original frequency. + m.frequency = 15 * time.Minute + return m.mrw.RemoveFile() +} + +type offlineWatcher struct { + client *service.DeviceClient + swiftDialogPath string + // swiftDialogCh is shared with the migrator and used to ensure only one dialog is open at a time + swiftDialogCh chan struct{} + fileWatcher migration.FileWatcher +} + +// StartMDMMigrationOfflineWatcher starts a watcher running on a 3-minute loop that checks if the +// device goes offline in the process of migrating to Fleet's MDM and offline. If so, it shows a +// dialog to prompt the user to connect to the internet. +func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) MDMOfflineWatcher { + if cap(swiftDialogCh) != 1 { + log.Fatal().Msg("swift dialog channel must have a buffer size of 1") + } + + watcher := &offlineWatcher{ + client: client, + swiftDialogPath: swiftDialogPath, + swiftDialogCh: swiftDialogCh, + fileWatcher: fileWatcher, + } + + // start loop with 3-minute interval to ping server and show dialog if offline + go func() { + ticker := time.NewTicker(constant.MDMMigrationOfflineWatcherInterval) + defer ticker.Stop() + + log.Info().Msg("starting watcher loop") + for { + select { + case <-ctx.Done(): + log.Debug().Msg("stopping offline dialog loop") + return + case <-ticker.C: + log.Debug().Msg("offline dialog, got tick") + go watcher.ShowIfOffline(ctx) + } + } + }() + + return watcher +} + +// ShowIfOffline shows the offline dialog if the host is offline. +// It returns true if the host is offline, and false otherwise. +func (o *offlineWatcher) ShowIfOffline(ctx context.Context) bool { + // try the dialog channel + select { + case o.swiftDialogCh <- struct{}{}: + log.Debug().Msg("occupying dialog channel") + default: + log.Debug().Msg("dialog channel already occupied") + return false + } + + defer func() { + // non-blocking release of dialog channel + select { + case <-o.swiftDialogCh: + log.Debug().Msg("releasing dialog channel") + default: + // this shouldn't happen so log for debugging + log.Debug().Msg("dialog channel already released") + } + }() + + if !o.isUnmanaged() || !o.isOffline() { + return false + } + + log.Info().Msg("showing offline dialog") + if err := o.showSwiftDialogMDMMigrationOffline(ctx); err != nil { + log.Error().Err(err).Msg("error showing offline dialog") + } else { + log.Info().Msg("done showing offline dialog") + } + + return true +} + +func (o *offlineWatcher) isUnmanaged() bool { + mt, err := o.fileWatcher.GetMigrationType() + if err != nil { + log.Error().Err(err).Msg("getting migration type") + } + + if mt == "" { + log.Debug().Msg("offline dialog, no migration type found, do nothing") + return false + } + + log.Debug().Msgf("offline dialog, device is unmanaged, migration type %s", mt) + + return true +} + +func (o *offlineWatcher) isOffline() bool { + err := o.client.Ping() + if err == nil { + log.Debug().Msg("offline dialog, ping ok, device is online") + return false + } + if !isOfflineError(err) { + log.Error().Err(err).Msg("offline dialog, error pinging server does not contain dial tcp or no such host, assuming device is online") + return false + } + log.Debug().Err(err).Msg("offline dialog, error pinging server, assuming device is offline") + + return true +} + +func isOfflineError(err error) bool { + if err == nil { + return false + } + offlineMsgs := []string{"no such host", "dial tcp", "no route to host"} + for _, msg := range offlineMsgs { + if strings.Contains(err.Error(), msg) { + return true + } + } + + // // NOTE: We're starting with basic string matching and planning to improve error matching + // // in future iterations. Here's some ideas for stuff to add in addition to strings.Contains: + // if urlErr, ok := err.(*url.Error); ok { + // log.Info().Msg("is url error") + // if urlErr.Timeout() { + // log.Info().Msg("is timeout") + // return true + // } + // // Check for no such host error + // if opErr, ok := urlErr.Err.(*net.OpError); ok { + // log.Info().Msg("is net op error") + // if dnsErr, ok := opErr.Err.(*net.DNSError); ok { + // log.Info().Msg("is dns error") + // if dnsErr.Err == "no such host" { + // log.Info().Msg("is dns no such host") + // return true + // } + // } + // } + // } + + return false +} + +// ShowDialogMDMMigrationOffline displays the dialog every time is called +func (o *offlineWatcher) showSwiftDialogMDMMigrationOffline(ctx context.Context) error { + props := MDMMigratorProps{ + DisableTakeover: true, + } + m := swiftDialogMDMMigrationOffline{ + baseDialog: newBaseDialog(o.swiftDialogPath), + props: props, + } + + flags, err := m.getFlags() + if err != nil { + return fmt.Errorf("getting flags for offline dialog: %w", err) + } + + exitCodeCh, errCh := m.render(flags...) + + select { + case <-ctx.Done(): + log.Debug().Msg("dialog context canceled") + m.baseDialog.Exit() + return nil + case err := <-errCh: + return fmt.Errorf("showing offline dialog: %w", err) + case <-exitCodeCh: + // there's only one button, so we don't need to check the exit code + log.Info().Msg("closing offline dialog") + return nil + } +} + +type swiftDialogMDMMigrationOffline struct { + *baseDialog + props MDMMigratorProps +} + +func (m *swiftDialogMDMMigrationOffline) render(flags ...string) (chan swiftDialogExitCode, chan error) { + return m.baseDialog.render(flags...) +} + +func (m *swiftDialogMDMMigrationOffline) getFlags() ([]string, error) { + tmpl := mdmMigrationTemplateOffline + var message bytes.Buffer + if err := tmpl.Execute( + &message, + nil, + ); err != nil { + return nil, fmt.Errorf("executing migration template: %w", err) + } + + // disable the built-in title and icon so we have full control over content + title := "none" + icon := "none" + + flags := []string{ + "--height", "124", + "--alignment", "center", + "--title", title, + "--icon", icon, + // modal content + "--message", message.String(), + "--messagefont", "size=16", + // main button + "--button1text", "Close", + } + + if !m.props.DisableTakeover { + flags = append(flags, + "--blurscreen", + "--ontop", + ) + } + + return flags, nil +} diff --git a/orbit/pkg/useraction/mdm_migration_darwin_test.go b/orbit/pkg/useraction/mdm_migration_darwin_test.go index 76be50f964..d71cb6fbb6 100644 --- a/orbit/pkg/useraction/mdm_migration_darwin_test.go +++ b/orbit/pkg/useraction/mdm_migration_darwin_test.go @@ -17,6 +17,7 @@ func (d dummyHandler) NotifyRemote() error { func (d dummyHandler) ShowInstructions() error { return nil } func TestWaitForUnenrollment(t *testing.T) { + t.Parallel() m := &swiftDialogMDMMigrator{ handler: dummyHandler{}, baseDialog: newBaseDialog("foo/bar"), @@ -51,7 +52,7 @@ func TestWaitForUnenrollment(t *testing.T) { return true, "example.com", nil } - outErr := m.waitForUnenrollment() + outErr := m.waitForUnenrollment(true) if c.wantErr { require.Error(t, outErr) } else { @@ -70,7 +71,27 @@ func TestWaitForUnenrollment(t *testing.T) { return false, "", nil } - outErr := m.waitForUnenrollment() + outErr := m.waitForUnenrollment(true) require.NoError(t, outErr) }) + + t.Run("only check file during ADE enrollment", func(t *testing.T) { + var fileWasChecked bool + m.testEnrollmentCheckFileFn = func() (bool, error) { + fileWasChecked = true + return true, nil + } + + m.testEnrollmentCheckStatusFn = func() (bool, string, error) { + return false, "", nil + } + + err := m.waitForUnenrollment(false) + require.NoError(t, err) + require.False(t, fileWasChecked) + + err = m.waitForUnenrollment(true) + require.NoError(t, err) + require.True(t, fileWasChecked) + }) } diff --git a/orbit/pkg/useraction/mdm_migration_notdarwin.go b/orbit/pkg/useraction/mdm_migration_notdarwin.go index 98615a193c..5b16b395e0 100644 --- a/orbit/pkg/useraction/mdm_migration_notdarwin.go +++ b/orbit/pkg/useraction/mdm_migration_notdarwin.go @@ -2,16 +2,32 @@ package useraction -import "time" +import ( + "context" + "time" -func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator { + "github.com/fleetdm/fleet/v4/orbit/pkg/migration" + "github.com/fleetdm/fleet/v4/server/service" +) + +func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator { return &NoopMDMMigrator{} } +func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) MDMOfflineWatcher { + return &NoopOfflineWatcher{} +} + +type NoopOfflineWatcher struct{} + +func (o *NoopOfflineWatcher) ShowIfOffline(ctx context.Context) bool { return false } + type NoopMDMMigrator struct{} -func (m *NoopMDMMigrator) CanRun() bool { return false } -func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {} -func (m *NoopMDMMigrator) Show() error { return nil } -func (m *NoopMDMMigrator) ShowInterval() error { return nil } -func (m *NoopMDMMigrator) Exit() {} +func (m *NoopMDMMigrator) CanRun() bool { return false } +func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {} +func (m *NoopMDMMigrator) Show() error { return nil } +func (m *NoopMDMMigrator) ShowInterval() error { return nil } +func (m *NoopMDMMigrator) Exit() {} +func (m *NoopMDMMigrator) MigrationInProgress() (string, error) { return "", nil } +func (m *NoopMDMMigrator) MarkMigrationCompleted() error { return nil } diff --git a/package.json b/package.json index 7171ac7fbf..29f8e60a66 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,15 @@ "dependencies": { "@types/dompurify": "3.0.2", "ace-builds": "1.4.12", - "axios": "1.6.0", + "axios": "1.7.4", + "content-disposition": "0.5.4", "core-js": "3.25.1", "date-fns": "3.6.0", "date-fns-tz": "3.1.3", - "dompurify": "3.0.3", + "dompurify": "3.1.3", "es6-object-assign": "1.1.0", "es6-promise": "4.2.8", - "express": "4.19.2", + "express": "4.20.0", "file-saver": "1.3.8", "history": "2.1.0", "isomorphic-fetch": "3.0.0", @@ -99,6 +100,7 @@ "@tsconfig/recommended": "1.0.1", "@types/chrome": "0.0.237", "@types/classnames": "0.0.32", + "@types/content-disposition": "0.5.4", "@types/expect": "1.20.3", "@types/file-saver": "2.0.5", "@types/jest": "29.5.12", diff --git a/pkg/download/download.go b/pkg/download/download.go index 89fbea8c3e..23c0f5c152 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -79,6 +79,10 @@ func download(client *http.Client, u *url.URL, path string, extract bool) error } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + r := io.Reader(resp.Body) // extract (optional) diff --git a/pkg/file/deb.go b/pkg/file/deb.go index 81351e23d3..35d5881342 100644 --- a/pkg/file/deb.go +++ b/pkg/file/deb.go @@ -33,9 +33,9 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) { return nil, fmt.Errorf("failed to advance to next file in archive: %w", err) } - name := path.Clean(hdr.Name) - if strings.HasPrefix(name, "control.tar") { - ext := filepath.Ext(name) + filename := path.Clean(hdr.Name) + if strings.HasPrefix(filename, "control.tar") { + ext := filepath.Ext(filename) if ext == ".tar" { ext = "" } @@ -49,9 +49,10 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) { return nil, fmt.Errorf("failed to read all content: %w", err) } return &InstallerMetadata{ - Name: name, - Version: version, - SHASum: h.Sum(nil), + Name: name, + Version: version, + PackageIDs: []string{name}, + SHASum: h.Sum(nil), }, nil } } diff --git a/pkg/file/file.go b/pkg/file/file.go index 5b061bcf07..7abd79169f 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -25,6 +25,7 @@ type InstallerMetadata struct { BundleIdentifier string SHASum []byte Extension string + PackageIDs []string } // ExtractInstallerMetadata extracts the software name and version from the diff --git a/pkg/file/management.go b/pkg/file/management.go index f5f981d840..26d1294952 100644 --- a/pkg/file/management.go +++ b/pkg/file/management.go @@ -16,8 +16,7 @@ var installExeScript string //go:embed scripts/install_deb.sh var installDebScript string -// GetInstallScript returns a script that can be used to install the -// the given extension +// GetInstallScript returns a script that can be used to install the given extension func GetInstallScript(extension string) string { switch extension { case "msi": @@ -61,3 +60,32 @@ func GetRemoveScript(extension string) string { return "" } } + +//go:embed scripts/uninstall_exe.ps1 +var uninstallExeScript string + +//go:embed scripts/uninstall_pkg.sh +var uninstallPkgScript string + +//go:embed scripts/uninstall_msi.ps1 +var uninstallMsiScript string + +//go:embed scripts/uninstall_deb.sh +var uninstallDebScript string + +// GetUninstallScript returns a script that can be used to uninstall a +// software item with the given extension. +func GetUninstallScript(extension string) string { + switch extension { + case "msi": + return uninstallMsiScript + case "deb": + return uninstallDebScript + case "pkg": + return uninstallPkgScript + case "exe": + return uninstallExeScript + default: + return "" + } +} diff --git a/pkg/file/management_test.go b/pkg/file/management_test.go index 422bb637a6..8b5d8608c5 100644 --- a/pkg/file/management_test.go +++ b/pkg/file/management_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,35 +20,43 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -// Note: to update the goldens, run the tests with `-update`: +// Note: to update the goldens, delete testdata/scripts/* and run the tests with `-update`: // // go test ./pkg/file/... -update func TestGetInstallAndRemoveScript(t *testing.T) { - scriptsByType := map[string][2]string{ + t.Parallel() + scriptsByType := map[string]map[string]string{ "msi": { - "./scripts/install_msi.ps1", - "./scripts/remove_msi.ps1", + "install": "./scripts/install_msi.ps1", + "remove": "./scripts/remove_msi.ps1", + "uninstall": "./scripts/uninstall_msi.ps1", }, "pkg": { - "./scripts/install_pkg.sh", - "./scripts/remove_pkg.sh", + "install": "./scripts/install_pkg.sh", + "remove": "./scripts/remove_pkg.sh", + "uninstall": "./scripts/uninstall_pkg.sh", }, "deb": { - "./scripts/install_deb.sh", - "./scripts/remove_deb.sh", + "install": "./scripts/install_deb.sh", + "remove": "./scripts/remove_deb.sh", + "uninstall": "./scripts/uninstall_deb.sh", }, "exe": { - "./scripts/install_exe.ps1", - "./scripts/remove_exe.ps1", + "install": "./scripts/install_exe.ps1", + "remove": "./scripts/remove_exe.ps1", + "uninstall": "./scripts/uninstall_exe.ps1", }, } for itype, scripts := range scriptsByType { gotScript := GetInstallScript(itype) - assertGoldenMatches(t, scripts[0], gotScript, *update) + assertGoldenMatches(t, scripts["install"], gotScript, *update) gotScript = GetRemoveScript(itype) - assertGoldenMatches(t, scripts[1], gotScript, *update) + assertGoldenMatches(t, scripts["remove"], gotScript, *update) + + gotScript = GetUninstallScript(itype) + assertGoldenMatches(t, scripts["uninstall"], gotScript, *update) } } @@ -67,5 +76,5 @@ func assertGoldenMatches(t *testing.T, goldenFile string, actual string, update content, err := io.ReadAll(f) require.NoError(t, err) - require.Equal(t, string(content), actual) + assert.Equal(t, string(content), actual) } diff --git a/pkg/file/msi.go b/pkg/file/msi.go index 885c28a79b..2568261e64 100644 --- a/pkg/file/msi.go +++ b/pkg/file/msi.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/sassoftware/relic/v7/lib/comdoc" + "github.com/sassoftware/relic/v8/lib/comdoc" ) func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) { @@ -77,10 +77,12 @@ func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) { return nil, err } + // MSI installer product information properties: https://learn.microsoft.com/en-us/windows/win32/msi/property-reference#product-information-properties return &InstallerMetadata{ - Name: strings.TrimSpace(props["ProductName"]), - Version: strings.TrimSpace(props["ProductVersion"]), - SHASum: h.Sum(nil), + Name: strings.TrimSpace(props["ProductName"]), + Version: strings.TrimSpace(props["ProductVersion"]), + PackageIDs: []string{strings.TrimSpace(props["ProductCode"])}, + SHASum: h.Sum(nil), }, nil } diff --git a/pkg/file/pe.go b/pkg/file/pe.go index 1b0e053c09..70d2596ec0 100644 --- a/pkg/file/pe.go +++ b/pkg/file/pe.go @@ -50,10 +50,12 @@ func ExtractPEMetadata(r io.Reader) (*InstallerMetadata, error) { if err != nil { return nil, fmt.Errorf("error parsing PE version resources: %w", err) } + name := strings.TrimSpace(v["ProductName"]) return applySpecialCases(&InstallerMetadata{ - Name: strings.TrimSpace(v["ProductName"]), - Version: strings.TrimSpace(v["ProductVersion"]), - SHASum: h.Sum(nil), + Name: name, + Version: strings.TrimSpace(v["ProductVersion"]), + PackageIDs: []string{name}, + SHASum: h.Sum(nil), }, v), nil } diff --git a/pkg/file/pe_test.go b/pkg/file/pe_test.go new file mode 100644 index 0000000000..0bfd0776aa --- /dev/null +++ b/pkg/file/pe_test.go @@ -0,0 +1,21 @@ +package file + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractPEMetadata(t *testing.T) { + t.Parallel() + file, err := os.Open("testdata/software-installers/hello-world-installer.exe") + require.NoError(t, err) + meta, err := ExtractPEMetadata(file) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Equal(t, "Hello world", meta.Name) + assert.Equal(t, "1.0.0", meta.Version) + assert.Equal(t, []string{"Hello world"}, meta.PackageIDs) +} diff --git a/pkg/file/scripts/install_exe.ps1 b/pkg/file/scripts/install_exe.ps1 index f9d762b318..f4e83b4de3 100644 --- a/pkg/file/scripts/install_exe.ps1 +++ b/pkg/file/scripts/install_exe.ps1 @@ -1,16 +1,29 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + $exeFilePath = "${env:INSTALLER_PATH}" -# extract the name of the executable to use as the sub-directory name -$exeName = [System.IO.Path]::GetFileName($exeFilePath) -$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath) +try { -$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir - -# check if the directory does not exist, and create it if necessary -if (-not (Test-Path -Path $destinationPath)) { - New-Item -ItemType Directory -Path $destinationPath +# Add argument to install silently +# Argument to make install silent depends on installer, +# each installer might use different argument (usually it's "/S" or "/s") +$processOptions = @{ + FilePath = "$exeFilePath" + ArgumentList = "/S" + PassThru = $true + Wait = $true } + +# Start process and track exit code +$process = Start-Process @processOptions +$exitCode = $process.ExitCode -# copy the .exe file to the new sub-directory -$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName -Copy-Item -Path $exeFilePath -Destination $destinationExePath +# Prints the exit code +Write-Host "Install exit code: $exitCode" +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/pkg/file/scripts/install_msi.ps1 b/pkg/file/scripts/install_msi.ps1 index 838c431c1d..fbd89aa10b 100644 --- a/pkg/file/scripts/install_msi.ps1 +++ b/pkg/file/scripts/install_msi.ps1 @@ -1,9 +1,16 @@ $logFile = "${env:TEMP}/fleet-install-software.log" +try { + $installProcess = Start-Process msiexec.exe ` -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` -PassThru -Verb RunAs -Wait Get-Content $logFile -Tail 500 -exit $installProcess.ExitCode +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/pkg/file/scripts/uninstall_deb.sh b/pkg/file/scripts/uninstall_deb.sh new file mode 100644 index 0000000000..b88a15a880 --- /dev/null +++ b/pkg/file/scripts/uninstall_deb.sh @@ -0,0 +1,4 @@ +package_name=$PACKAGE_ID + +# Fleet uninstalls app using product name that's extracted on upload +apt-get remove --purge --assume-yes "$package_name" diff --git a/pkg/file/scripts/uninstall_exe.ps1 b/pkg/file/scripts/uninstall_exe.ps1 new file mode 100644 index 0000000000..bc6ea14214 --- /dev/null +++ b/pkg/file/scripts/uninstall_exe.ps1 @@ -0,0 +1,91 @@ +# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID +# variable +$softwareName = $PACKAGE_ID + +# It is recommended to use exact software name here if possible to avoid +# uninstalling unintended software. +$softwareNameLike = "*$softwareName*" + +# Some uninstallers require a flag to run silently. +# Each uninstaller might use different argument (usually it's "/S" or "/s") +$uninstallArgs = "/S" + +$machineKey = ` + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +$machineKey32on64 = ` + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + +$exitCode = 0 + +try { + +[array]$uninstallKeys = Get-ChildItem ` + -Path @($machineKey, $machineKey32on64) ` + -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath } + +$foundUninstaller = $false +foreach ($key in $uninstallKeys) { + # If needed, add -notlike to the comparison to exclude certain similar + # software + if ($key.DisplayName -like $softwareNameLike) { + $foundUninstaller = $true + # Get the uninstall command. Some uninstallers do not include + # 'QuietUninstallString' and require a flag to run silently. + $uninstallCommand = if ($key.QuietUninstallString) { + $key.QuietUninstallString + } else { + $key.UninstallString + } + + # The uninstall command may contain command and args, like: + # "C:\Program Files\Software\uninstall.exe" --uninstall --silent + # Split the command and args + $splitArgs = $uninstallCommand.Split('"') + if ($splitArgs.Length -gt 1) { + if ($splitArgs.Length -eq 3) { + $uninstallArgs = "$( $splitArgs[2] ) $uninstallArgs".Trim() + } elseif ($splitArgs.Length -gt 3) { + Throw ` + "Uninstall command contains multiple quoted strings. " + + "Please update the uninstall script.`n" + + "Uninstall command: $uninstallCommand" + } + $uninstallCommand = $splitArgs[1] + } + Write-Host "Uninstall command: $uninstallCommand" + Write-Host "Uninstall args: $uninstallArgs" + + $processOptions = @{ + FilePath = $uninstallCommand + PassThru = $true + Wait = $true + } + if ($uninstallArgs -ne '') { + $processOptions.ArgumentList = "$uninstallArgs" + } + + # Start process and track exit code + $process = Start-Process @processOptions + $exitCode = $process.ExitCode + + # Prints the exit code + Write-Host "Uninstall exit code: $exitCode" + # Exit the loop once the software is found and uninstalled. + break + } +} + +if (-not $foundUninstaller) { + Write-Host "Uninstaller for '$softwareName' not found." + # Change exit code to 0 if you don't want to fail if uninstaller is not + # found. This could happen if program was already uninstalled. + $exitCode = 1 +} + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} + +Exit $exitCode diff --git a/pkg/file/scripts/uninstall_msi.ps1 b/pkg/file/scripts/uninstall_msi.ps1 new file mode 100644 index 0000000000..ca6b350b2b --- /dev/null +++ b/pkg/file/scripts/uninstall_msi.ps1 @@ -0,0 +1,5 @@ +$product_code = $PACKAGE_ID + +# Fleet uninstalls app using product code that's extracted on upload +msiexec /quiet /x $product_code +Exit $LASTEXITCODE diff --git a/pkg/file/scripts/uninstall_pkg.sh b/pkg/file/scripts/uninstall_pkg.sh new file mode 100644 index 0000000000..473cff971b --- /dev/null +++ b/pkg/file/scripts/uninstall_pkg.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# Fleet extracts and saves package IDs. +pkg_ids=$PACKAGE_ID + +# Get all files associated with package and remove them +for pkg_id in "${pkg_ids[@]}" +do + # Get volume and location of package + volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i + + + + + + + + ZeroInstallSize + diff --git a/pkg/file/testdata/scripts/install_exe.ps1.golden b/pkg/file/testdata/scripts/install_exe.ps1.golden index f9d762b318..f4e83b4de3 100644 --- a/pkg/file/testdata/scripts/install_exe.ps1.golden +++ b/pkg/file/testdata/scripts/install_exe.ps1.golden @@ -1,16 +1,29 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + $exeFilePath = "${env:INSTALLER_PATH}" -# extract the name of the executable to use as the sub-directory name -$exeName = [System.IO.Path]::GetFileName($exeFilePath) -$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath) +try { -$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir - -# check if the directory does not exist, and create it if necessary -if (-not (Test-Path -Path $destinationPath)) { - New-Item -ItemType Directory -Path $destinationPath +# Add argument to install silently +# Argument to make install silent depends on installer, +# each installer might use different argument (usually it's "/S" or "/s") +$processOptions = @{ + FilePath = "$exeFilePath" + ArgumentList = "/S" + PassThru = $true + Wait = $true } + +# Start process and track exit code +$process = Start-Process @processOptions +$exitCode = $process.ExitCode -# copy the .exe file to the new sub-directory -$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName -Copy-Item -Path $exeFilePath -Destination $destinationExePath +# Prints the exit code +Write-Host "Install exit code: $exitCode" +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/pkg/file/testdata/scripts/install_msi.ps1.golden b/pkg/file/testdata/scripts/install_msi.ps1.golden index 838c431c1d..fbd89aa10b 100644 --- a/pkg/file/testdata/scripts/install_msi.ps1.golden +++ b/pkg/file/testdata/scripts/install_msi.ps1.golden @@ -1,9 +1,16 @@ $logFile = "${env:TEMP}/fleet-install-software.log" +try { + $installProcess = Start-Process msiexec.exe ` -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` -PassThru -Verb RunAs -Wait Get-Content $logFile -Tail 500 -exit $installProcess.ExitCode +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/pkg/file/testdata/scripts/uninstall_deb.sh.golden b/pkg/file/testdata/scripts/uninstall_deb.sh.golden new file mode 100644 index 0000000000..b88a15a880 --- /dev/null +++ b/pkg/file/testdata/scripts/uninstall_deb.sh.golden @@ -0,0 +1,4 @@ +package_name=$PACKAGE_ID + +# Fleet uninstalls app using product name that's extracted on upload +apt-get remove --purge --assume-yes "$package_name" diff --git a/pkg/file/testdata/scripts/uninstall_exe.ps1.golden b/pkg/file/testdata/scripts/uninstall_exe.ps1.golden new file mode 100644 index 0000000000..bc6ea14214 --- /dev/null +++ b/pkg/file/testdata/scripts/uninstall_exe.ps1.golden @@ -0,0 +1,91 @@ +# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID +# variable +$softwareName = $PACKAGE_ID + +# It is recommended to use exact software name here if possible to avoid +# uninstalling unintended software. +$softwareNameLike = "*$softwareName*" + +# Some uninstallers require a flag to run silently. +# Each uninstaller might use different argument (usually it's "/S" or "/s") +$uninstallArgs = "/S" + +$machineKey = ` + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +$machineKey32on64 = ` + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + +$exitCode = 0 + +try { + +[array]$uninstallKeys = Get-ChildItem ` + -Path @($machineKey, $machineKey32on64) ` + -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath } + +$foundUninstaller = $false +foreach ($key in $uninstallKeys) { + # If needed, add -notlike to the comparison to exclude certain similar + # software + if ($key.DisplayName -like $softwareNameLike) { + $foundUninstaller = $true + # Get the uninstall command. Some uninstallers do not include + # 'QuietUninstallString' and require a flag to run silently. + $uninstallCommand = if ($key.QuietUninstallString) { + $key.QuietUninstallString + } else { + $key.UninstallString + } + + # The uninstall command may contain command and args, like: + # "C:\Program Files\Software\uninstall.exe" --uninstall --silent + # Split the command and args + $splitArgs = $uninstallCommand.Split('"') + if ($splitArgs.Length -gt 1) { + if ($splitArgs.Length -eq 3) { + $uninstallArgs = "$( $splitArgs[2] ) $uninstallArgs".Trim() + } elseif ($splitArgs.Length -gt 3) { + Throw ` + "Uninstall command contains multiple quoted strings. " + + "Please update the uninstall script.`n" + + "Uninstall command: $uninstallCommand" + } + $uninstallCommand = $splitArgs[1] + } + Write-Host "Uninstall command: $uninstallCommand" + Write-Host "Uninstall args: $uninstallArgs" + + $processOptions = @{ + FilePath = $uninstallCommand + PassThru = $true + Wait = $true + } + if ($uninstallArgs -ne '') { + $processOptions.ArgumentList = "$uninstallArgs" + } + + # Start process and track exit code + $process = Start-Process @processOptions + $exitCode = $process.ExitCode + + # Prints the exit code + Write-Host "Uninstall exit code: $exitCode" + # Exit the loop once the software is found and uninstalled. + break + } +} + +if (-not $foundUninstaller) { + Write-Host "Uninstaller for '$softwareName' not found." + # Change exit code to 0 if you don't want to fail if uninstaller is not + # found. This could happen if program was already uninstalled. + $exitCode = 1 +} + +} catch { + Write-Host "Error: $_" + $exitCode = 1 +} + +Exit $exitCode diff --git a/pkg/file/testdata/scripts/uninstall_msi.ps1.golden b/pkg/file/testdata/scripts/uninstall_msi.ps1.golden new file mode 100644 index 0000000000..ca6b350b2b --- /dev/null +++ b/pkg/file/testdata/scripts/uninstall_msi.ps1.golden @@ -0,0 +1,5 @@ +$product_code = $PACKAGE_ID + +# Fleet uninstalls app using product code that's extracted on upload +msiexec /quiet /x $product_code +Exit $LASTEXITCODE diff --git a/pkg/file/testdata/scripts/uninstall_pkg.sh.golden b/pkg/file/testdata/scripts/uninstall_pkg.sh.golden new file mode 100644 index 0000000000..473cff971b --- /dev/null +++ b/pkg/file/testdata/scripts/uninstall_pkg.sh.golden @@ -0,0 +1,21 @@ +#!/bin/sh + +# Fleet extracts and saves package IDs. +pkg_ids=$PACKAGE_ID + +# Get all files associated with package and remove them +for pkg_id in "${pkg_ids[@]}" +do + # Get volume and location of package + volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i + + + + PRODUCT + %s + SERIAL + %s + UDID + %s + VERSION + 22A5316k + +`, c.Model, c.SerialNumber, c.UUID)) + + do := func(cert *x509.Certificate, key *rsa.PrivateKey) ([]byte, error) { + signedData, err := pkcs7.NewSignedData(rawDeviceInfo) + if err != nil { + return nil, fmt.Errorf("create signed data: %w", err) + } + err = signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{}) + if err != nil { + return nil, fmt.Errorf("add signer: %w", err) + } + sig, err := signedData.Finish() + if err != nil { + return nil, fmt.Errorf("finish signing: %w", err) + } + + request, err := http.NewRequest( + "POST", + otaEnrollmentProfile.PayloadContent.URL, + bytes.NewReader(sig), + ) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + // #nosec (this client is used for testing only) + cc := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{ + InsecureSkipVerify: true, + })) + response, err := cc.Do(request) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + return body, nil + } + + // TODO(roberto 09-10-2024): the first request in the OTA flow must be + // signed using a keypair that has a valid Apple certificate as root. I + // believe this could be done with a little bit of reverse + // engineering/cleverness but for now, we're signing the request with + // our mock certs and setting this env var to skip the verification. + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + mockedCert, mockedKey, err := apple_mdm.NewSCEPCACertKey() + if err != nil { + return fmt.Errorf("creating mock certificates: %w", err) + } + body, err = do(mockedCert, mockedKey) + if err != nil { + return fmt.Errorf("first OTA request: %w", err) + } + os.Unsetenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") + + var scepInfo struct { + PayloadContent []struct { + PayloadContent struct { + Challenge string `plist:"Challenge"` + URL string `plist:"URL"` + } `plist:"PayloadContent"` + } `plist:"PayloadContent"` + } + + err = plist.Unmarshal(body, &scepInfo) + if err != nil { + return fmt.Errorf("unmarshaling SCEP response: %w", err) + } + + tmpCert, tmpKey, err := c.doSCEP(scepInfo.PayloadContent[0].PayloadContent.URL, scepInfo.PayloadContent[0].PayloadContent.Challenge) + if err != nil { + return fmt.Errorf("get SCEP certificate for OTA: %w", err) + } + + body, err = do(tmpCert, tmpKey) + if err != nil { + return fmt.Errorf("seconde OTA request: %w", err) + } + p7, err = pkcs7.Parse(body) + if err != nil { + return fmt.Errorf("enrollment profile is not XML nor PKCS7 parseable: %w", err) + } + err = p7.Verify() + if err != nil { + return fmt.Errorf("verifying enrollment profile: %w", err) + } + enrollInfo, err := ParseEnrollmentProfile(p7.Content) + if err != nil { + return fmt.Errorf("parse OTA SCEP profile: %w", err) + } + c.EnrollInfo = *enrollInfo + return nil +} + func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { request, err := http.NewRequest("GET", c.fleetServerURL+path, nil) if err != nil { @@ -212,6 +402,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { if err != nil { return fmt.Errorf("send request: %w", err) } + defer response.Body.Close() if response.StatusCode != http.StatusOK { return fmt.Errorf("request error: %d, %s", response.StatusCode, response.Status) } @@ -247,8 +438,7 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error { return nil } -// SCEPEnroll runs the SCEP enroll protocol for the simulated device. -func (c *TestAppleMDMClient) SCEPEnroll() error { +func (c *TestAppleMDMClient) doSCEP(url, challenge string) (*x509.Certificate, *rsa.PrivateKey, error) { ctx := context.Background() var logger log.Logger @@ -257,25 +447,25 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } else { logger = kitlog.NewNopLogger() } - client, err := newSCEPClient(c.EnrollInfo.SCEPURL, logger) + client, err := newSCEPClient(url, logger) if err != nil { - return fmt.Errorf("scep client: %w", err) + return nil, nil, fmt.Errorf("scep client: %w", err) } // (1). Get the CA certificate from the SCEP server. resp, _, err := client.GetCACert(ctx, "") if err != nil { - return fmt.Errorf("get CA cert: %w", err) + return nil, nil, fmt.Errorf("get CA cert: %w", err) } caCert, err := x509.ParseCertificates(resp) if err != nil { - return fmt.Errorf("parse CA cert: %w", err) + return nil, nil, fmt.Errorf("parse CA cert: %w", err) } // (2). Generate RSA key pair. devicePrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - return fmt.Errorf("generate RSA private key: %w", err) + return nil, nil, fmt.Errorf("generate RSA private key: %w", err) } // (3). Generate CSR. @@ -288,15 +478,15 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { }, SignatureAlgorithm: x509.SHA256WithRSA, }, - ChallengePassword: c.EnrollInfo.SCEPChallenge, + ChallengePassword: challenge, } csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, devicePrivateKey) if err != nil { - return fmt.Errorf("create CSR: %w", err) + return nil, nil, fmt.Errorf("create CSR: %w", err) } csr, err := x509.ParseCertificateRequest(csrDerBytes) if err != nil { - return fmt.Errorf("parse CSR: %w", err) + return nil, nil, fmt.Errorf("parse CSR: %w", err) } // (4). SCEP requires a certificate for client authentication. We generate a new one @@ -312,7 +502,7 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) certSerialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { - return fmt.Errorf("generate cert serial number: %w", err) + return nil, nil, fmt.Errorf("generate cert serial number: %w", err) } deviceCertificateTemplate := x509.Certificate{ SerialNumber: certSerialNumber, @@ -334,11 +524,11 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { devicePrivateKey, ) if err != nil { - return fmt.Errorf("create device certificate: %w", err) + return nil, nil, fmt.Errorf("create device certificate: %w", err) } deviceCertificateForRequest, err := x509.ParseCertificate(deviceCertificateDerBytes) if err != nil { - return fmt.Errorf("parse device certificate: %w", err) + return nil, nil, fmt.Errorf("parse device certificate: %w", err) } // (5). Send the PKCSReq message to the SCEP server. @@ -353,31 +543,40 @@ func (c *TestAppleMDMClient) SCEPEnroll() error { } msg, err := scep.NewCSRRequest(csr, pkiMsgReq, scep.WithLogger(logger)) if err != nil { - return fmt.Errorf("create CSR request: %w", err) + return nil, nil, fmt.Errorf("create CSR request: %w", err) } respBytes, err := client.PKIOperation(ctx, msg.Raw) if err != nil { - return fmt.Errorf("do CSR request: %w", err) + return nil, nil, fmt.Errorf("do CSR request: %w", err) } pkiMsgResp, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients)) if err != nil { - return fmt.Errorf("parse PKIMessage response: %w", err) + return nil, nil, fmt.Errorf("parse PKIMessage response: %w", err) } if pkiMsgResp.PKIStatus != scep.SUCCESS { - return fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) + return nil, nil, fmt.Errorf("PKIMessage CSR request failed with code: %s, fail info: %s", pkiMsgResp.PKIStatus, pkiMsgResp.FailInfo) } if err := pkiMsgResp.DecryptPKIEnvelope(deviceCertificateForRequest, devicePrivateKey); err != nil { - return fmt.Errorf("decrypt PKI envelope: %w", err) + return nil, nil, fmt.Errorf("decrypt PKI envelope: %w", err) } - // (6). Finally, set the signed certificate returned from the server as the device certificate and key. - c.scepCert = pkiMsgResp.CertRepMessage.Certificate - c.scepKey = devicePrivateKey - if c.debug { fmt.Println("SCEP enrollment successful") } + // (6). return the signed certificate returned from the server as the device certificate and key. + return pkiMsgResp.CertRepMessage.Certificate, devicePrivateKey, nil +} + +// SCEPEnroll runs the SCEP enroll protocol for the simulated device. +func (c *TestAppleMDMClient) SCEPEnroll() error { + cert, key, err := c.doSCEP(c.EnrollInfo.SCEPURL, c.EnrollInfo.SCEPChallenge) + if err != nil { + return err + } + + c.scepCert = cert + c.scepKey = key return nil } diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index b6eea43708..9e4adc2268 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -7,9 +7,11 @@ import ( "os" "path/filepath" "slices" + "strings" "unicode" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/ghodss/yaml" "github.com/hashicorp/go-multierror" "golang.org/x/text/unicode/norm" @@ -35,11 +37,33 @@ type Controls struct { EnableDiskEncryption interface{} `json:"enable_disk_encryption"` Scripts []BaseItem `json:"scripts"` + + Defined bool +} + +func (c Controls) Set() bool { + return c.MacOSUpdates != nil || c.IOSUpdates != nil || + c.IPadOSUpdates != nil || c.MacOSSettings != nil || + c.MacOSSetup != nil || c.MacOSMigration != nil || + c.WindowsUpdates != nil || c.WindowsSettings != nil || c.WindowsEnabledAndConfigured != nil || + c.EnableDiskEncryption != nil || len(c.Scripts) > 0 } type Policy struct { BaseItem + GitOpsPolicySpec +} + +type GitOpsPolicySpec struct { fleet.PolicySpec + InstallSoftware *PolicyInstallSoftware `json:"install_software"` + // InstallSoftwareURL is populated after parsing the software installer yaml + // referenced by InstallSoftware.PackagePath. + InstallSoftwareURL string `json:"-"` +} + +type PolicyInstallSoftware struct { + PackagePath string `json:"package_path"` } type Query struct { @@ -47,6 +71,16 @@ type Query struct { fleet.QuerySpec } +type SoftwarePackage struct { + BaseItem + fleet.SoftwarePackageSpec +} + +type Software struct { + Packages []SoftwarePackage `json:"packages"` + AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` +} + type GitOps struct { TeamID *uint TeamName *string @@ -54,19 +88,21 @@ type GitOps struct { OrgSettings map[string]interface{} AgentOptions *json.RawMessage Controls Controls - Policies []*fleet.PolicySpec + Policies []*GitOpsPolicySpec Queries []*fleet.QuerySpec // Software is only allowed on teams, not on global config. Software GitOpsSoftware } type GitOpsSoftware struct { - Packages []*fleet.TeamSpecSoftwarePackage + Packages []*fleet.SoftwarePackageSpec AppStoreApps []*fleet.TeamSpecAppStoreApp } +type Logf func(format string, a ...interface{}) + // GitOpsFromFile parses a GitOps yaml file. -func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) { +func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig, logFn Logf) (*GitOps, error) { b, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %s: %w", filePath, err) @@ -96,27 +132,44 @@ func GitOpsFromFile(filePath, baseDir string) (*GitOps, error) { // Figure out if this is an org or team settings file teamRaw, teamOk := top["name"] teamSettingsRaw, teamSettingsOk := top["team_settings"] - teamSoftware, teamSoftwareOk := top["software"] orgSettingsRaw, orgOk := top["org_settings"] if orgOk { - if teamOk || teamSettingsOk || teamSoftwareOk { - multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings' or 'software'")) + if teamOk || teamSettingsOk { + multiError = multierror.Append(multiError, errors.New("'org_settings' cannot be used with 'name', 'team_settings'")) } else { multiError = parseOrgSettings(orgSettingsRaw, result, baseDir, multiError) } - } else if teamOk && teamSettingsOk { + } else if teamOk { multiError = parseName(teamRaw, result, multiError) - multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) - multiError = parseSoftware(teamSoftware, result, baseDir, multiError) + if result.IsNoTeam() { + if teamSettingsOk { + multiError = multierror.Append(multiError, fmt.Errorf("cannot set 'team_settings' on 'No team' file: %q", filePath)) + } + if filepath.Base(filePath) != "no-team.yml" { + multiError = multierror.Append(multiError, fmt.Errorf("file %q for 'No team' must be named 'no-team.yml'", filePath)) + } + } else { + if !teamSettingsOk { + multiError = multierror.Append(multiError, errors.New("'team_settings' is required when 'name' is provided")) + } else { + multiError = parseTeamSettings(teamSettingsRaw, result, baseDir, multiError) + } + } } else { multiError = multierror.Append(multiError, errors.New("either 'org_settings' or 'name' and 'team_settings' is required")) } // Validate the required top level options multiError = parseControls(top, result, baseDir, multiError) - multiError = parseAgentOptions(top, result, baseDir, multiError) + multiError = parseAgentOptions(top, result, baseDir, logFn, multiError) + multiError = parseQueries(top, result, baseDir, logFn, multiError) + + if appConfig != nil && appConfig.License.IsPremium() { + multiError = parseSoftware(top, result, baseDir, multiError) + } + + // Policies can reference software installers, thus we parse them after parseSoftware. multiError = parsePolicies(top, result, baseDir, multiError) - multiError = parseQueries(top, result, baseDir, multiError) return result, multiError.ErrorOrNil() } @@ -134,6 +187,20 @@ func parseName(raw json.RawMessage, result *GitOps, multiError *multierror.Error return multiError } +func (g *GitOps) global() bool { + return g.TeamName == nil || *g.TeamName == "" +} + +func (g *GitOps) IsNoTeam() bool { + return g.TeamName != nil && isNoTeam(*g.TeamName) +} + +func isNoTeam(teamName string) bool { + return strings.ToLower(teamName) == strings.ToLower(noTeam) +} + +const noTeam = "No team" + func parseOrgSettings(raw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { var orgSettingsTop BaseItem if err := json.Unmarshal(raw, &orgSettingsTop); err != nil { @@ -287,9 +354,14 @@ func parseSecrets(result *GitOps, multiError *multierror.Error) *multierror.Erro return multiError } -func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { +func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error { agentOptionsRaw, ok := top["agent_options"] - if !ok { + if result.IsNoTeam() { + if ok { + logFn("[!] 'agent_options' is not supported for \"No team\". This key will be ignored.\n") + } + return multiError + } else if !ok { return multierror.Append(multiError, errors.New("'agent_options' is required")) } var agentOptionsTop BaseItem @@ -339,12 +411,14 @@ func parseAgentOptions(top map[string]json.RawMessage, result *GitOps, baseDir s func parseControls(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { controlsRaw, ok := top["controls"] if !ok { - return multierror.Append(multiError, errors.New("'controls' is required")) + // Nothing to do, return. + return multiError } var controlsTop Controls if err := json.Unmarshal(controlsRaw, &controlsTop); err != nil { return multierror.Append(multiError, fmt.Errorf("failed to unmarshal controls: %v", err)) } + controlsTop.Defined = true if controlsTop.Path == nil { result.Controls = controlsTop } else { @@ -387,7 +461,11 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin for _, item := range policies { item := item if item.Path == nil { - result.Policies = append(result.Policies, &item.PolicySpec) + if err := parsePolicyInstallSoftware(baseDir, result.TeamName, &item, result.Software.Packages); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", item.Name, err)) + continue + } + result.Policies = append(result.Policies, &item.GitOpsPolicySpec) } else { fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) if err != nil { @@ -414,7 +492,11 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path), ) } else { - result.Policies = append(result.Policies, &pp.PolicySpec) + if err := parsePolicyInstallSoftware(baseDir, result.TeamName, pp, result.Software.Packages); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", pp.Name, err)) + continue + } + result.Policies = append(result.Policies, &pp.GitOpsPolicySpec) } } } @@ -436,9 +518,12 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin } else { item.Team = "" } + if item.CalendarEventsEnabled && result.IsNoTeam() { + multiError = multierror.Append(multiError, fmt.Errorf("calendar events are not supported on \"No team\" policies: %q", item.Name)) + } } duplicates := getDuplicateNames( - result.Policies, func(p *fleet.PolicySpec) string { + result.Policies, func(p *GitOpsPolicySpec) string { return p.Name }, ) @@ -448,9 +533,47 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin return multiError } -func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { +func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec) error { + if policy.InstallSoftware == nil { + policy.SoftwareTitleID = ptr.Uint(0) // unset the installer + return nil + } + if policy.InstallSoftware != nil && policy.InstallSoftware.PackagePath != "" && teamName == nil { + return errors.New("install_software can only be set on team policies") + } + if policy.InstallSoftware.PackagePath == "" { + return errors.New("empty package_path") + } + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, policy.InstallSoftware.PackagePath)) + if err != nil { + return fmt.Errorf("failed to read install_software.package_path file %q: %v", policy.InstallSoftware.PackagePath, err) + } + var policyInstallSoftwareSpec fleet.SoftwarePackageSpec + if err := yaml.Unmarshal(fileBytes, &policyInstallSoftwareSpec); err != nil { + return fmt.Errorf("failed to unmarshal install_software.package_path file %s: %v", policy.InstallSoftware.PackagePath, err) + } + installerOnTeamFound := false + for _, pkg := range packages { + if pkg.URL == policyInstallSoftwareSpec.URL { + installerOnTeamFound = true + break + } + } + if !installerOnTeamFound { + return fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, policy.InstallSoftware.PackagePath) + } + policy.InstallSoftwareURL = policyInstallSoftwareSpec.URL + return nil +} + +func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, logFn Logf, multiError *multierror.Error) *multierror.Error { queriesRaw, ok := top["queries"] - if !ok { + if result.IsNoTeam() { + if ok { + logFn("[!] 'queries' is not supported for \"No team\". This key will be ignored.\n") + } + return multiError + } else if !ok { return multierror.Append(multiError, errors.New("'queries' key is required")) } var queries []Query @@ -523,32 +646,62 @@ func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string return multiError } -func parseSoftware(softwareRaw json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { - var software fleet.TeamSpecSoftware +func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { + softwareRaw, ok := top["software"] + if result.global() { + if ok && string(softwareRaw) != "null" { + return multierror.Append(multiError, errors.New("'software' cannot be set on global file")) + } + } else if !ok { + return multierror.Append(multiError, errors.New("'software' is required")) + } + var software Software if len(softwareRaw) > 0 { if err := json.Unmarshal(softwareRaw, &software); err != nil { - return multierror.Append(multiError, fmt.Errorf("failed to unmarshall software: %v", err)) + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + typeErrField := typeErr.Field + if typeErrField == "" { + // UnmarshalTypeError.Field is empty when trying to set an invalid type on the root node. + typeErrField = "software" + } + return multierror.Append(multiError, fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErrField, typeErr.Type.String(), typeErr.Value)) + } + return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err)) } } - if software.AppStoreApps.Set { - for _, item := range software.AppStoreApps.Value { - item := item - if item.AppStoreID == "" { - multiError = multierror.Append(multiError, errors.New("software app store id required")) - continue - } - result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) + for _, item := range software.AppStoreApps { + item := item + if item.AppStoreID == "" { + multiError = multierror.Append(multiError, errors.New("software app store id required")) + continue } + result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) } - if software.Packages.Set { - for _, item := range software.Packages.Value { - item := item - if item.URL == "" { - multiError = multierror.Append(multiError, errors.New("software URL is required")) + for _, item := range software.Packages { + var softwarePackageSpec fleet.SoftwarePackageSpec + if item.Path != nil { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err)) continue } - result.Software.Packages = append(result.Software.Packages, &item) + if err := yaml.Unmarshal(fileBytes, &softwarePackageSpec); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal software package file %s: %v", *item.Path, err)) + continue + } + } else { + softwarePackageSpec = item.SoftwarePackageSpec } + if softwarePackageSpec.URL == "" { + multiError = multierror.Append(multiError, errors.New("software URL is required")) + continue + } + if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength { + multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be less than 256 characters", softwarePackageSpec.URL)) + continue + } + result.Software.Packages = append(result.Software.Packages, &softwarePackageSpec) } return multiError diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 644c3e453e..65a73ab7c2 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -5,8 +5,10 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -51,9 +53,22 @@ func createTempFile(t *testing.T, pattern, contents string) (filePath string, ba return tmpFile.Name(), filepath.Dir(tmpFile.Name()) } +func createNamedFileOnTempDir(t *testing.T, name string, contents string) (filePath string, baseDir string) { + tmpFilePath := filepath.Join(t.TempDir(), name) + tmpFile, err := os.Create(tmpFilePath) + require.NoError(t, err) + _, err = tmpFile.WriteString(contents) + require.NoError(t, err) + require.NoError(t, tmpFile.Close()) + return tmpFile.Name(), filepath.Dir(tmpFile.Name()) +} + func gitOpsFromString(t *testing.T, s string) (*GitOps, error) { path, basePath := createTempFile(t, "", s) - return GitOpsFromFile(path, basePath) + return GitOpsFromFile(path, basePath, nil, nopLogf) +} + +func nopLogf(_ string, _ ...interface{}) { } func TestValidGitOpsYaml(t *testing.T) { @@ -104,11 +119,17 @@ func TestValidGitOpsYaml(t *testing.T) { os.Unsetenv(k) } }) - } else { - t.Parallel() } - gitops, err := GitOpsFromFile(test.filePath, "./testdata") + var appConfig *fleet.EnrichedAppConfig + if test.isTeam { + appConfig = &fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + } + + gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig, nopLogf) require.NoError(t, err) if test.isTeam { @@ -132,6 +153,14 @@ func TestValidGitOpsYaml(t *testing.T) { require.Len(t, secrets.([]*fleet.EnrollSecret), 2) assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) + require.Len(t, gitops.Software.Packages, 2) + for _, pkg := range gitops.Software.Packages { + if strings.Contains(pkg.URL, "MicrosoftTeams") { + assert.Equal(t, "uninstall.sh", pkg.UninstallScript.Path) + } else { + assert.Empty(t, pkg.UninstallScript.Path) + } + } } else { // Check org settings serverSettings, ok := gitops.OrgSettings["server_settings"] @@ -201,14 +230,32 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Equal(t, "darwin,linux,windows", gitops.Queries[1].Platform) assert.Equal(t, "osquery_info", gitops.Queries[2].Name) + // Check software + if test.isTeam { + require.Len(t, gitops.Software.Packages, 2) + require.Equal(t, "https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg", gitops.Software.Packages[0].URL) + require.False(t, gitops.Software.Packages[0].SelfService) + require.Equal(t, "https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg", gitops.Software.Packages[1].URL) + require.True(t, gitops.Software.Packages[1].SelfService) + } + // Check policies - require.Len(t, gitops.Policies, 5) + expectedPoliciesCount := 5 + if test.isTeam { + expectedPoliciesCount = 6 + } + require.Len(t, gitops.Policies, expectedPoliciesCount) assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name) assert.Equal(t, "Passing policy", gitops.Policies[1].Name) assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name) assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name) assert.Equal(t, "linux", gitops.Policies[3].Platform) assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name) + if test.isTeam { + assert.Equal(t, "Microsoft Teams on macOS installed and up to date", gitops.Policies[5].Name) + assert.NotNil(t, gitops.Policies[5].InstallSoftware) + assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath) + } }, ) } @@ -336,20 +383,20 @@ func TestMixingGlobalAndTeamConfig(t *testing.T) { config := getGlobalConfig(nil) config += "name: TeamName\n" _, err := gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") // Mixing org_settings and team_settings config = getGlobalConfig(nil) config += "team_settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") // Mixing org_settings and team name and team_settings config = getGlobalConfig(nil) config += "name: TeamName\n" config += "team_settings:\n secrets: []\n" _, err = gitOpsFromString(t, config) - assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings' or 'software'") + assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name', 'team_settings'") } func TestInvalidGitOpsYaml(t *testing.T) { @@ -415,14 +462,44 @@ func TestInvalidGitOpsYaml(t *testing.T) { _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "must have a 'secret' key") + // Missing team_settings. + config = getConfig([]string{"team_settings"}) + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "'team_settings' is required when 'name' is provided") + + // team_settings set on a "no-team.yml". + config = getConfig([]string{"name"}) + config += "name: No team\n" + noTeamPath1, noTeamBasePath1 := createNamedFileOnTempDir(t, "no-team.yml", config) + _, err = GitOpsFromFile(noTeamPath1, noTeamBasePath1, nil, nopLogf) + assert.ErrorContains(t, err, fmt.Sprintf("cannot set 'team_settings' on 'No team' file: %q", noTeamPath1)) + + // 'No team' file with invalid name. + config = getConfig([]string{"name", "team_settings"}) + config += "name: No team\n" + noTeamPath2, noTeamBasePath2 := createNamedFileOnTempDir(t, "foobar.yml", config) + _, err = GitOpsFromFile(noTeamPath2, noTeamBasePath2, nil, nopLogf) + assert.ErrorContains(t, err, fmt.Sprintf("file %q for 'No team' must be named 'no-team.yml'", noTeamPath2)) + // Missing secrets config = getConfig([]string{"team_settings"}) config += "team_settings:\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "'team_settings.secrets' is required") } else { + // 'software' is not allowed in global config + config := getConfig(nil) + config += "software:\n packages:\n - url: https://example.com\n" + path1, basePath1 := createTempFile(t, "", config) + appConfig := fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + _, err = GitOpsFromFile(path1, basePath1, &appConfig, nopLogf) + assert.ErrorContains(t, err, "'software' cannot be set on global file") + // Invalid org_settings - config := getConfig([]string{"org_settings"}) + config = getConfig([]string{"org_settings"}) config += "org_settings:\n path: [2]\n" _, err = gitOpsFromString(t, config) assert.ErrorContains(t, err, "failed to unmarshal org_settings") @@ -567,9 +644,6 @@ func TestTopLevelGitOpsValidation(t *testing.T) { "missing_all": { optsToExclude: []string{"controls", "queries", "policies", "agent_options", "org_settings"}, }, - "missing_controls": { - optsToExclude: []string{"controls"}, - }, "missing_queries": { optsToExclude: []string{"queries"}, }, @@ -696,7 +770,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.NoError(t, err) // Test a bad path @@ -709,7 +783,7 @@ func TestGitOpsPaths(t *testing.T) { err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "no such file or directory") // Test a bad file -- cannot be unmarshalled @@ -744,13 +818,127 @@ func TestGitOpsPaths(t *testing.T) { } err = os.WriteFile(mainTmpFile.Name(), []byte(config), 0o644) require.NoError(t, err) - _, err = GitOpsFromFile(mainTmpFile.Name(), dir) + _, err = GitOpsFromFile(mainTmpFile.Name(), dir, nil, nopLogf) assert.ErrorContains(t, err, "nested paths are not supported") }, ) } } +func TestGitOpsGlobalPolicyWithInstallSoftware(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: ./some_path.yml +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "install_software can only be set on team policies") +} + +func TestGitOpsTeamPolicyWithInvalidInstallSoftware(t *testing.T) { + t.Parallel() + config := getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: ./some_path.yml +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "failed to read install_software.package_path file") + + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "empty package_path") + + // Software has a URL that's too big + tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 232)) + config = getTeamConfig([]string{"software"}) + config += fmt.Sprintf(` +software: + packages: + - url: %s +`, tooBigURL) + appConfig := fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + path, basePath := createTempFile(t, "", config) + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) + assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be less than 256 characters", tooBigURL)) + + // Policy references a software installer not present in the team. + config = getTeamConfig([]string{"policies"}) + config += ` +policies: + - path: ./team_install_software.policies.yml +software: + packages: + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true + +` + path, basePath = createTempFile(t, "", config) + err = file.Copy( + filepath.Join("testdata", "team_install_software.policies.yml"), + filepath.Join(basePath, "team_install_software.policies.yml"), + 0o755, + ) + require.NoError(t, err) + err = file.Copy( + filepath.Join("testdata", "microsoft-teams.pkg.software.yml"), + filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), + 0o755, + ) + require.NoError(t, err) + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) + assert.ErrorContains(t, err, + "install_software.package_path URL https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg not found on team", + ) + + // Policy references a software installer file that has an invalid yaml. + config = getTeamConfig([]string{"policies"}) + config += ` +policies: + - path: ./team_install_software.policies.yml +software: + packages: + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true +` + path, basePath = createTempFile(t, "", config) + err = file.Copy( + filepath.Join("testdata", "team_install_software.policies.yml"), + filepath.Join(basePath, "team_install_software.policies.yml"), + 0o755, + ) + require.NoError(t, err) + err = os.WriteFile( // nolint:gosec + filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), + []byte("invalid yaml"), + 0o755, + ) + require.NoError(t, err) + appConfig = fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) + assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file") +} + func getGlobalConfig(optsToExclude []string) string { return getBaseConfig(topLevelOptions, optsToExclude) } diff --git a/pkg/spec/spec.go b/pkg/spec/spec.go index d760f8b1d3..10bafd76da 100644 --- a/pkg/spec/spec.go +++ b/pkg/spec/spec.go @@ -26,6 +26,7 @@ type Group struct { Packs []*fleet.PackSpec Labels []*fleet.LabelSpec Policies []*fleet.PolicySpec + Software []*fleet.SoftwarePackageSpec // This needs to be interface{} to allow for the patch logic. Otherwise we send a request that looks to the // server like the user explicitly set the zero values. AppConfig interface{} diff --git a/pkg/spec/testdata/microsoft-teams.pkg.software.yml b/pkg/spec/testdata/microsoft-teams.pkg.software.yml new file mode 100644 index 0000000000..664068cb94 --- /dev/null +++ b/pkg/spec/testdata/microsoft-teams.pkg.software.yml @@ -0,0 +1,4 @@ +url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg +self_service: false +uninstall_script: + path: uninstall.sh diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml index 127ed82bcf..8d593283a0 100644 --- a/pkg/spec/testdata/team_config.yml +++ b/pkg/spec/testdata/team_config.yml @@ -25,3 +25,9 @@ policies: description: This policy should always fail. resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - path: ./team_install_software.policies.yml +software: + packages: + - path: ./microsoft-teams.pkg.software.yml + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml index 44c4ff36b0..c0c0742d72 100644 --- a/pkg/spec/testdata/team_config_no_paths.yml +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -115,3 +115,13 @@ policies: description: This policy should always fail. resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Microsoft Teams on macOS installed and up to date + platform: darwin + query: SELECT 1 FROM apps WHERE name = 'Microsoft Teams.app' AND version_compare(bundle_short_version, '24193.1707.3028.4282') >= 0; + install_software: + package_path: ./microsoft-teams.pkg.software.yml +software: + packages: + - path: ./microsoft-teams.pkg.software.yml + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true diff --git a/pkg/spec/testdata/team_install_software.policies.yml b/pkg/spec/testdata/team_install_software.policies.yml new file mode 100644 index 0000000000..1811340788 --- /dev/null +++ b/pkg/spec/testdata/team_install_software.policies.yml @@ -0,0 +1,5 @@ +- name: Microsoft Teams on macOS installed and up to date + platform: darwin + query: SELECT 1 FROM apps WHERE name = 'Microsoft Teams.app' AND version_compare(bundle_short_version, '24193.1707.3028.4282') >= 0; + install_software: + package_path: ./microsoft-teams.pkg.software.yml diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index ed0f676276..db3567e3dc 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -5349,7 +5349,7 @@ }, { "name": "cryptoinfo", - "description": "Get info about the a certificate on the host.", + "description": "Get info about a certificate on the host.", "evented": false, "notes": "This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher).", "platforms": [ @@ -12799,7 +12799,6 @@ "index": false } ], - "hidden": true, "fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/iptables.yml" }, { diff --git a/schema/tables/cryptoinfo.yml b/schema/tables/cryptoinfo.yml index 4fd8d5da37..38055381b7 100644 --- a/schema/tables/cryptoinfo.yml +++ b/schema/tables/cryptoinfo.yml @@ -1,5 +1,5 @@ name: cryptoinfo -description: Get info about the a certificate on the host. +description: Get info about a certificate on the host. evented: false notes: This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher). platforms: diff --git a/schema/tables/iptables.yml b/schema/tables/iptables.yml index 8e560b85a0..564ecfeb86 100644 --- a/schema/tables/iptables.yml +++ b/schema/tables/iptables.yml @@ -1,2 +1 @@ -name: iptables -hidden: true +name: iptables \ No newline at end of file diff --git a/scripts/macos-install-wine.sh b/scripts/macos-install-wine.sh index fc89c8398c..0b7b0752cc 100755 --- a/scripts/macos-install-wine.sh +++ b/scripts/macos-install-wine.sh @@ -7,7 +7,8 @@ set -eo pipefail brew_wine(){ # Wine reference: https://wiki.winehq.org/MacOS # Wine can be installed without brew via a distribution such as https://github.com/Gcenx/macOS_Wine_builds/releases/tag/9.0 or by building from source. -brew install --cask --no-quarantine https://raw.githubusercontent.com/Homebrew/homebrew-cask/1ecfe82f84e0f3c3c6b741d3ddc19a164c2cb18d/Casks/w/wine-stable.rb; exit 0 +curl -O https://raw.githubusercontent.com/Homebrew/homebrew-cask/1ecfe82f84e0f3c3c6b741d3ddc19a164c2cb18d/Casks/w/wine-stable.rb +brew install --cask --no-quarantine wine-stable.rb; exit 0 } diff --git a/server/authz/policy.rego b/server/authz/policy.rego index fbdaf51659..11a9a07c59 100644 --- a/server/authz/policy.rego +++ b/server/authz/policy.rego @@ -677,7 +677,7 @@ allow { # Host software installs ## -# Global admins and maintainers can write (install) software on hosts (not +# Global admins and maintainers can write (install/uninstall) software on hosts (not # gitops as this is not something that relates to fleetctl apply). allow { object.type == "host_software_installer_result" @@ -685,7 +685,7 @@ allow { action == write } -# Team admin and maintainers can write (install) software on hosts for their +# Team admin and maintainers can write (install/uninstall) software on hosts for their # teams (not gitops as this is not something that relates to fleetctl apply). allow { object.type == "host_software_installer_result" @@ -733,11 +733,11 @@ allow { action == [read, write][_] } -# Global admins can read and write MDM apple information. +# Global admins can read, write, and list MDM apple information. allow { object.type == "mdm_apple" subject.global_role == admin - action == [read, write][_] + action == [read, write, list][_] } # Global admins can read and write Apple MDM enrollments. @@ -937,7 +937,7 @@ allow { action == write } -# Global admins, maintainers, observer_plus and observers can read scripts. +# Global admins, maintainers, observer_plus and observers can read script results, including software uninstall results. allow { object.type == "host_script_result" subject.global_role == [admin, maintainer, observer, observer_plus][_] @@ -953,7 +953,7 @@ allow { action == write } -# Team admins, maintainers, observer_plus and observers can read scripts for their teams. +# Team admins, maintainers, observer_plus and observers can read script results for their teams, including software uninstall results. allow { object.type == "host_script_result" not is_null(object.team_id) diff --git a/server/config/config.go b/server/config/config.go index 9e4cb6003a..5f6a9164eb 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -99,9 +99,12 @@ type ServerConfig struct { func (s *ServerConfig) DefaultHTTPServer(ctx context.Context, handler http.Handler) *http.Server { return &http.Server{ - Addr: s.Address, - Handler: handler, - ReadTimeout: 25 * time.Second, + Addr: s.Address, + Handler: handler, + ReadTimeout: 25 * time.Second, + // WriteTimeout is set for security purposes. + // If we don't set it, (bugy or malignant) clients making long running + // requests could DDOS Fleet. WriteTimeout: 40 * time.Second, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 5 * time.Minute, @@ -622,6 +625,7 @@ type CalendarConfig struct { func (c *CalendarConfig) AlwaysReloadEvent() bool { return c.alwaysReloadEvent } + func (c *CalendarConfig) SetAlwaysReloadEvent(value bool) { c.alwaysReloadEvent = value } @@ -714,8 +718,7 @@ func (m *MDMConfig) IsAppleBMSet() bool { return pair.IsSet() || m.AppleBMServerToken != "" || m.AppleBMServerTokenBytes != "" } -// AppleAPNs returns the parsed and validated TLS certificate for Apple APNs. -// It parses and validates it if it hasn't been done yet. +// AppleAPNs returns the parsed TLS certificate for Apple APNs. func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte, err error) { if m.appleAPNs == nil { pair := x509KeyPairConfig{ @@ -735,8 +738,7 @@ func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte, return m.appleAPNs, m.appleAPNsPEMCert, m.appleAPNsPEMKey, nil } -// AppleSCEP returns the parsed and validated TLS certificate for Apple SCEP. -// It parses and validates it if it hasn't been done yet. +// AppleSCEP returns the parsed TLS certificate for Apple SCEP. func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte, err error) { if m.appleSCEP == nil { pair := x509KeyPairConfig{ @@ -763,7 +765,7 @@ type ParsedAppleBM struct { Token *nanodep_client.OAuth1Tokens } -func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) { +func decryptABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) { bmKey, err := tokenpki.RSAKeyFromPEM(keyPEM) if err != nil { return nil, fmt.Errorf("Apple BM configuration: parse private key: %w", err) @@ -776,14 +778,11 @@ func decryptAndValidateABMToken(tokenBytes []byte, cert *x509.Certificate, keyPE if err := json.Unmarshal(token, &jsonTok); err != nil { return nil, fmt.Errorf("Apple BM configuration: unmarshal JSON token: %w", err) } - if jsonTok.AccessTokenExpiry.Before(time.Now()) { - return nil, errors.New("Apple BM configuration: token is expired") - } return &jsonTok, nil } -// AppleBM returns the parsed, validated and decrypted server token for Apple -// Business Manager. It also parses and validates the Apple BM certificate and +// AppleBM returns the parsed and decrypted server token for Apple +// Business Manager. It also parses the Apple BM certificate and // private key in the process, in order to decrypt the token. func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) { if m.appleBMToken == nil { @@ -801,7 +800,7 @@ func (m *MDMConfig) AppleBM() (*ParsedAppleBM, error) { if err != nil { return nil, fmt.Errorf("Apple BM configuration: %w", err) } - jsonTok, err := decryptAndValidateABMToken(encToken, cert.Leaf, pair.keyBytes) + jsonTok, err := decryptABMToken(encToken, cert.Leaf, pair.keyBytes) if err != nil { return nil, err } diff --git a/server/contexts/apple_bm/apple_bm.go b/server/contexts/apple_bm/apple_bm.go new file mode 100644 index 0000000000..e6024a9a78 --- /dev/null +++ b/server/contexts/apple_bm/apple_bm.go @@ -0,0 +1,20 @@ +package apple_bm + +import ( + "context" + + depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" +) + +type key int + +const abmKey key = 0 + +func NewContext(ctx context.Context, decryptedToken *depclient.OAuth1Tokens) context.Context { + return context.WithValue(ctx, abmKey, decryptedToken) +} + +func FromContext(ctx context.Context) (*depclient.OAuth1Tokens, bool) { + tok, ok := ctx.Value(abmKey).(*depclient.OAuth1Tokens) + return tok, ok +} diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go index 4e717a84b6..72b1498af2 100644 --- a/server/cron/calendar_cron.go +++ b/server/cron/calendar_cron.go @@ -326,7 +326,7 @@ func processFailingHostExistingCalendarEvent( // Try to acquire the lock. Lock is needed to ensure calendar callback is not processed for this event at the same time. eventUUID := calendarEvent.UUID lockValue := uuid.New().String() - lockAcquired, err := distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, calendar.DistributedLockExpireMs) + lockAcquired, err := distributedLock.SetIfNotExist(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, calendar.DistributedLockExpireMs) if err != nil { return fmt.Errorf("acquire calendar lock: %w", err) } @@ -334,7 +334,7 @@ func processFailingHostExistingCalendarEvent( lockReserved := false if !lockAcquired { // Lock was not acquired. We reserve the lock and try to acquire it until we do. - lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue, + lockAcquired, err = distributedLock.SetIfNotExist(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue, calendar.ReserveLockExpireMs) if err != nil { return fmt.Errorf("reserve calendar lock: %w", err) @@ -348,7 +348,7 @@ func processFailingHostExistingCalendarEvent( go func() { for { // Keep trying to get the lock. - lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, + lockAcquired, err = distributedLock.SetIfNotExist(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, calendar.DistributedLockExpireMs) if err != nil || lockAcquired { done <- struct{}{} @@ -396,6 +396,7 @@ func processFailingHostExistingCalendarEvent( // Function to generate calendar event body. var generatedTag string + var newETag string var genBodyFn fleet.CalendarGenBodyFn = func(conflict bool) (string, bool, error) { var body string body, generatedTag = calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger) @@ -409,7 +410,7 @@ func processFailingHostExistingCalendarEvent( updatedBodyTag := getBodyTag(ctx, ds, host, policyIDtoPolicy, logger) if currentBodyTag != updatedBodyTag && updatedBodyTag != "" { - err = userCalendar.UpdateEventBody(calendarEvent, genBodyFn) + newETag, err = userCalendar.UpdateEventBody(calendarEvent, genBodyFn) if err != nil { return fmt.Errorf("update event body: %w", err) } @@ -440,8 +441,8 @@ func processFailingHostExistingCalendarEvent( } if updated { - if generatedTag != "" { - err = updatedEvent.SaveBodyTag(generatedTag) + if generatedTag != "" && newETag != "" { + err = updatedEvent.SaveDataItems("body_tag", generatedTag, "etag", newETag) if err != nil { return fmt.Errorf("save calendar event body tag: %w", err) } @@ -623,7 +624,7 @@ func attemptCreatingEventOnUserCalendar( var dee fleet.DayEndedError switch { case err == nil: - err = calendarEvent.SaveBodyTag(generatedTag) + err = calendarEvent.SaveDataItems("body_tag", generatedTag) if err != nil { return nil, err } diff --git a/server/cron/calendar_cron_test.go b/server/cron/calendar_cron_test.go index b4cb9141b9..3e780ee515 100644 --- a/server/cron/calendar_cron_test.go +++ b/server/cron/calendar_cron_test.go @@ -401,6 +401,7 @@ func (n notFoundErr) Error() string { } func TestCalendarEvents1KHosts(t *testing.T) { + t.Parallel() ds := new(mock.Store) ctx := context.Background() var logger kitlog.Logger diff --git a/server/datastore/filesystem/software_installer.go b/server/datastore/filesystem/software_installer.go index 7bb68a87cc..e9a1eb891e 100644 --- a/server/datastore/filesystem/software_installer.go +++ b/server/datastore/filesystem/software_installer.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -93,7 +94,7 @@ func (i *SoftwareInstallerStore) Exists(ctx context.Context, installerID string) return true, nil } -func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string) (int, error) { +func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error) { usedSet := make(map[string]struct{}, len(usedInstallerIDs)) for _, id := range usedInstallerIDs { usedSet[id] = struct{}{} @@ -115,6 +116,14 @@ func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs [ if _, isUsed := usedSet[de.Name()]; isUsed { continue } + + info, err := de.Info() + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "get software installer modtime in filesystem store") + } + if info.ModTime().After(removeCreatedBefore) { + continue + } if err := os.Remove(filepath.Join(baseDir, de.Name())); err != nil { errs = append(errs, err) } else { diff --git a/server/datastore/filesystem/software_installer_test.go b/server/datastore/filesystem/software_installer_test.go index 3b942df885..6e7b0e454a 100644 --- a/server/datastore/filesystem/software_installer_test.go +++ b/server/datastore/filesystem/software_installer_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/google/uuid" @@ -107,7 +108,7 @@ func TestSoftwareInstallerCleanup(t *testing.T) { } // cleanup an empty store - n, err := store.Cleanup(ctx, nil) + n, err := store.Cleanup(ctx, nil, time.Now()) require.NoError(t, err) require.Equal(t, 0, n) @@ -117,14 +118,14 @@ func TestSoftwareInstallerCleanup(t *testing.T) { require.NoError(t, err) // cleanup but mark it as used - n, err = store.Cleanup(ctx, []string{ins0}) + n, err = store.Cleanup(ctx, []string{ins0}, time.Now()) require.NoError(t, err) require.Equal(t, 0, n) assertExisting([]string{ins0}) // cleanup but mark it as unused - n, err = store.Cleanup(ctx, []string{}) + n, err = store.Cleanup(ctx, []string{}, time.Now()) require.NoError(t, err) require.Equal(t, 1, n) @@ -137,9 +138,15 @@ func TestSoftwareInstallerCleanup(t *testing.T) { require.NoError(t, err) } - n, err = store.Cleanup(ctx, []string{installers[0], installers[2]}) + // cleanup with a time in the past, nothing gets removed + n, err = store.Cleanup(ctx, []string{}, time.Now().Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 0, n) + assertExisting([]string{installers[0], installers[1], installers[2], installers[3]}) + + // cleanup in the future, all unused get removed + n, err = store.Cleanup(ctx, []string{installers[0], installers[2]}, time.Now().Add(time.Minute)) require.NoError(t, err) require.Equal(t, 2, n) - assertExisting([]string{installers[0], installers[2]}) } diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index d0e6dee037..650c097cb9 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -31,7 +31,12 @@ func (ds *Datastore) NewActivity( var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } @@ -237,15 +242,22 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint `SELECT COUNT(*) c FROM host_script_results hsr + LEFT OUTER JOIN + host_software_installs hsi ON hsi.execution_id = hsr.execution_id WHERE hsr.host_id = :host_id AND exit_code IS NULL AND - (sync_request = 0 OR created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND))`, + hsi.execution_id IS NULL AND + (sync_request = 0 OR hsr.created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND))`, `SELECT COUNT(*) c FROM host_software_installs hsi WHERE hsi.host_id = :host_id AND - pre_install_query_output IS NULL AND - install_script_exit_code IS NULL`, + hsi.status = :software_status_install_pending`, + `SELECT + COUNT(*) c + FROM host_software_installs hsi + WHERE hsi.host_id = :host_id AND + hsi.status = :software_status_uninstall_pending`, ` SELECT COUNT(*) c @@ -260,8 +272,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint seconds := int(scripts.MaxServerWaitTime.Seconds()) countStmt, args, err := sqlx.Named(countStmt, map[string]any{ - "host_id": hostID, - "max_wait_time": seconds, + "host_id": hostID, + "max_wait_time": seconds, + "software_status_install_pending": fleet.SoftwareInstallPending, + "software_status_uninstall_pending": fleet.SoftwareUninstallPending, }) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build count query from named args") @@ -300,21 +314,26 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint host_display_names hdn ON hdn.host_id = hsr.host_id LEFT OUTER JOIN scripts scr ON scr.id = hsr.script_id + LEFT OUTER JOIN + host_software_installs hsi ON hsi.execution_id = hsr.execution_id WHERE hsr.host_id = :host_id AND hsr.exit_code IS NULL AND ( hsr.sync_request = 0 OR hsr.created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND) - ) + ) AND + hsi.execution_id IS NULL `, // list pending software installs fmt.Sprintf(`SELECT hsi.execution_id as uuid, - u.name as name, - u.id as user_id, - u.gravatar_url as gravatar_url, - u.email as user_email, + -- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0), + -- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer. + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email, :installed_software_type as activity_type, hsi.created_at as created_at, JSON_OBJECT( @@ -323,8 +342,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint 'software_title', COALESCE(st.name, ''), 'software_package', si.filename, 'install_uuid', hsi.execution_id, - 'status', CAST(%s AS CHAR), - 'self_service', si.self_service IS TRUE + 'status', CAST(hsi.status AS CHAR), + 'self_service', hsi.self_service IS TRUE ) as details FROM host_software_installs hsi @@ -334,13 +353,48 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint software_titles st ON st.id = si.title_id LEFT OUTER JOIN users u ON u.id = hsi.user_id + LEFT OUTER JOIN + users u2 ON u2.id = si.user_id LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hsi.host_id WHERE hsi.host_id = :host_id AND - hsi.pre_install_query_output IS NULL AND - hsi.install_script_exit_code IS NULL - `, softwareInstallerHostStatusNamedQuery("hsi", "")), + hsi.status = :software_status_install_pending + `), + // list pending software uninstalls + fmt.Sprintf(`SELECT + hsi.execution_id as uuid, + -- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0), + -- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer. + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email, + :uninstalled_software_type as activity_type, + hsi.created_at as created_at, + JSON_OBJECT( + 'host_id', hsi.host_id, + 'host_display_name', COALESCE(hdn.display_name, ''), + 'software_title', COALESCE(st.name, ''), + 'script_execution_id', hsi.execution_id, + 'status', CAST(hsi.status AS CHAR) + ) as details + FROM + host_software_installs hsi + INNER JOIN + software_installers si ON si.id = hsi.software_installer_id + LEFT OUTER JOIN + software_titles st ON st.id = si.title_id + LEFT OUTER JOIN + users u ON u.id = hsi.user_id + LEFT OUTER JOIN + users u2 ON u2.id = si.user_id + LEFT OUTER JOIN + host_display_names hdn ON hdn.host_id = hsi.host_id + WHERE + hsi.host_id = :host_id AND + hsi.status = :software_status_uninstall_pending + `), ` SELECT hvsi.command_uuid AS uuid, @@ -356,8 +410,9 @@ SELECT 'software_title', st.name, 'app_store_id', hvsi.adam_id, 'command_uuid', hvsi.command_uuid, + 'self_service', hvsi.self_service IS TRUE, -- status is always pending because only pending MDM commands are upcoming. - 'status', :software_status_pending + 'status', :software_status_install_pending ) AS details FROM host_vpp_software_installs hvsi @@ -389,14 +444,14 @@ WHERE details FROM ( ` + strings.Join(listStmts, " UNION ALL ") + ` ) AS upcoming ` listStmt, args, err = sqlx.Named(listStmt, map[string]any{ - "host_id": hostID, - "ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(), - "installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(), - "installed_app_store_app_type": fleet.ActivityInstalledAppStoreApp{}.ActivityName(), - "max_wait_time": seconds, - "software_status_failed": string(fleet.SoftwareInstallerFailed), - "software_status_installed": string(fleet.SoftwareInstallerInstalled), - "software_status_pending": string(fleet.SoftwareInstallerPending), + "host_id": hostID, + "ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(), + "installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(), + "uninstalled_software_type": fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), + "installed_app_store_app_type": fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + "max_wait_time": seconds, + "software_status_install_pending": fleet.SoftwareInstallPending, + "software_status_uninstall_pending": fleet.SoftwareUninstallPending, }) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index e91a825227..17bd72e6ee 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -371,10 +371,15 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { u2 := test.NewUser(t, ds, "user2", "user2@example.com", false) ctx := viewer.NewContext(noUserCtx, viewer.Viewer{User: u2}) + test.CreateInsertGlobalVPPToken(t, ds) + // create three hosts h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now()) + nanoEnrollAndSetHostMDMData(t, ds, h1, false) h2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now()) + nanoEnrollAndSetHostMDMData(t, ds, h2, false) h3 := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now()) + nanoEnrollAndSetHostMDMData(t, ds, h3, false) // create a couple of named scripts scr1, err := ds.NewScript(ctx, &fleet.Script{ @@ -398,6 +403,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: u.ID, }) require.NoError(t, err) sw2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -408,6 +414,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "bar", Source: "apps", Version: "0.0.2", + UserID: u.ID, }) require.NoError(t, err) sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1) @@ -415,6 +422,35 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { sw2Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw2) require.NoError(t, err) + // insert a VPP app + vppCommand1, vppCommand2 := "vpp-command-1", "vpp-command-2" + vppApp := &fleet.VPPApp{ + Name: "vpp_no_team_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, + BundleIdentifier: "b3", + } + _, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil) + require.NoError(t, err) + + // install the VPP app on h1 + commander, _ := createMDMAppleCommanderAndStorage(t, ds) + err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp.VPPAppID, vppCommand1, "event-id-1", false) + require.NoError(t, err) + err = commander.EnqueueCommand( + ctx, + []string{h1.UUID}, + createRawAppleCmd("InstallApplication", vppCommand1), + ) + require.NoError(t, err) + // install the VPP app on h2, self-service + err = ds.InsertHostVPPSoftwareInstall(noUserCtx, h2.ID, vppApp.VPPAppID, vppCommand2, "event-id-2", true) + require.NoError(t, err) + err = commander.EnqueueCommand( + ctx, + []string{h1.UUID}, + createRawAppleCmd("InstallApplication", vppCommand2), + ) + require.NoError(t, err) + // create a sync script request for h1 that has been pending for > MaxWaitTime, will not show up hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "sync", UserID: &u.ID, SyncRequest: true}) require.NoError(t, err) @@ -460,7 +496,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { InstallScriptExitCode: ptr.Int(0), }) require.NoError(t, err) - h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one + + // No user for this one and not Self-service, means it was installed by Fleet thus the author was decided to be the admin + // that uploaded the installer. + h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) require.NoError(t, err) // create a single pending request for h2, as well as a non-pending one @@ -469,12 +508,16 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h2A := hsr.ExecutionID hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "F", UserID: &u.ID}) require.NoError(t, err) - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0}) + _, _, err = ds.SetHostScriptExecutionResult(ctx, + &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0}) require.NoError(t, err) h2F := hsr.ExecutionID // add a pending software install request for h2 h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false) require.NoError(t, err) + // No user for this one and Self-service, means it was installed by the end user, so the user_id should be null/nil. + h2Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, true) + require.NoError(t, err) // nothing for h3 @@ -483,20 +526,26 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooFailed, h1Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h1C, h1D, h1E) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooInstalled, h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Foo) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Bar) - SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F) + SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2) execIDsWithUser := map[string]bool{ - h1A: true, - h1B: true, - h1C: true, - h1D: false, - h1E: false, - h2A: true, - h2F: true, - h1Foo: false, - h1Bar: true, - h2Bar: true, + h1A: true, + h1B: true, + h1C: true, + h1D: false, + h1E: false, + h2A: true, + h2F: true, + h1Foo: true, + h2Foo: false, + h1Bar: true, + h2Bar: true, + vppCommand1: true, + vppCommand2: false, } execIDsScriptName := map[string]string{ h1A: scr1.Name, @@ -507,6 +556,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h1Foo: "foo", h1Bar: "bar", h2Bar: "bar", + h2Foo: "foo", + } + execIDsWithUserAdminID := map[string]struct{}{ + h1Foo: {}, } cases := []struct { @@ -519,49 +572,49 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { opts: fleet.ListOptions{PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1A, h1B}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, }, { opts: fleet.ListOptions{Page: 1, PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1Bar, h1C}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 8}, }, { opts: fleet.ListOptions{Page: 2, PerPage: 2}, hostID: h1.ID, wantExecs: []string{h1D, h1E}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 8}, }, { opts: fleet.ListOptions{Page: 3, PerPage: 2}, hostID: h1.ID, - wantExecs: []string{h1Foo}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + wantExecs: []string{h1Foo, vppCommand1}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { opts: fleet.ListOptions{PerPage: 4}, hostID: h1.ID, wantExecs: []string{h1A, h1B, h1Bar, h1C}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, }, { opts: fleet.ListOptions{Page: 1, PerPage: 4}, hostID: h1.ID, - wantExecs: []string{h1D, h1E, h1Foo}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + wantExecs: []string{h1D, h1E, h1Foo, vppCommand1}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { opts: fleet.ListOptions{Page: 2, PerPage: 4}, hostID: h1.ID, wantExecs: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{PerPage: 3}, + opts: fleet.ListOptions{PerPage: 4}, hostID: h2.ID, - wantExecs: []string{h2Bar, h2A}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2}, + wantExecs: []string{h2Foo, h2Bar, h2A, vppCommand2}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 4}, }, { opts: fleet.ListOptions{}, @@ -602,6 +655,16 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { case fleet.ActivityTypeInstalledSoftware{}.ActivityName(): require.Equal(t, wantExec, details["install_uuid"], "result %d", i) require.Equal(t, execIDsSoftwareTitle[wantExec], details["software_title"], "result %d", i) + if _, ok := execIDsWithUserAdminID[details["install_uuid"].(string)]; ok { + wantUser = u + } else { + wantUser = u2 + } + + case fleet.ActivityInstalledAppStoreApp{}.ActivityName(): + require.Equal(t, wantExec, details["command_uuid"], "result %d", i) + require.Equal(t, "vpp_no_team_app_1", details["software_title"], "result %d", i) + require.Equal(t, !execIDsWithUser[wantExec], details["self_service"], "result %d", i) wantUser = u2 default: diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index a0e95f7d75..b4ae8c5ac0 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -7,10 +7,12 @@ import ( "crypto/cipher" "crypto/rand" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "math" "os" "strings" "time" @@ -25,10 +27,14 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) +// addHostMDMCommandsBatchSize is the number of host MDM commands to add in a single batch. This is a var so that it can be modified in tests. +var addHostMDMCommandsBatchSize = 10000 + func (ds *Datastore) NewMDMAppleConfigProfile(ctx context.Context, cp fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { profUUID := "a" + uuid.New().String() stmt := ` @@ -83,7 +89,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations") } @@ -890,48 +896,36 @@ func insertMDMAppleHostDB( return nil } -type hostWithEnrolled struct { - fleet.Host - Enrolled *bool `db:"enrolled"` +// hostToCreateFromMDM defines a common set of parameters required to create +// host records without a pre-existing osquery enrollment from MDM flows like +// ADE ingestion or OTA enrollments +type hostToCreateFromMDM struct { + // HardwareSerial should match the value for hosts.hardware_serial + HardwareSerial string + // HardwareModel should match the value for hosts.hardware_model + HardwareModel string + // PlatformHint is used to determine hosts.platform, if it: + // + // - contains "iphone" the platform is "ios" + // - contains "ipad" the platform is "ipados" + // - otherwise the platform is "darwin" + PlatformHint string } -func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device) (createdCount int64, teamID *uint, err error) { - if len(devices) < 1 { - level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) - return 0, nil, nil - } +func createHostFromMDMDB( + ctx context.Context, + tx sqlx.ExtContext, + logger log.Logger, + devices []hostToCreateFromMDM, + fromADE bool, + macOSTeam, iosTeam, ipadTeam *uint, +) (int64, []fleet.Host, error) { + // NOTE: order of arguments for teams is important, see statement. + args := []any{iosTeam, ipadTeam, macOSTeam} + us, unionArgs := unionSelectDevices(devices) + args = append(args, unionArgs...) - appCfg, err := ds.AppConfig(ctx) - if err != nil { - return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") - } - - args := []interface{}{nil} - if name := appCfg.MDM.AppleBMDefaultTeam; name != "" { - team, err := ds.TeamByName(ctx, name) - switch { - case fleet.IsNotFound(err): - level.Debug(ds.logger).Log( - "msg", - "ingesting devices from DEP: unable to find default team assigned in config, the devices won't be assigned to a team", - "team_name", - name, - ) - // If the team doesn't exist, we still ingest the device, but it won't - // belong to any team. - case err != nil: - return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") - default: - args[0] = team.ID - teamID = &team.ID - } - } - - err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - us, unionArgs := unionSelectDevices(devices) - args = append(args, unionArgs...) - - stmt := fmt.Sprintf(` + stmt := fmt.Sprintf(` INSERT INTO hosts ( hardware_serial, hardware_model, @@ -950,97 +944,190 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devic '2000-01-01 00:00:00' AS detail_updated_at, NULL AS osquery_host_id, IF(us.platform = 'ios' OR us.platform = 'ipados', 0, 1) AS refetch_requested, - ? AS team_id + CASE + WHEN us.platform = 'ios' THEN ? + WHEN us.platform = 'ipados' THEN ? + ELSE ? + END AS team_id FROM (%s) us LEFT JOIN hosts h ON us.hardware_serial = h.hardware_serial WHERE h.id IS NULL GROUP BY us.hardware_serial, us.platform)`, - us, - ) + us, + ) - res, err := tx.ExecContext(ctx, stmt, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync insert") - } + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "inserting new host in MDM ingestion") + } - n, err := res.RowsAffected() - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple hosts from dep sync rows affected") - } - createdCount = n + n, _ := res.RowsAffected() + // get new host ids + args = []any{} + parts := []string{} + for _, d := range devices { + args = append(args, d.HardwareSerial) + parts = append(parts, "?") + } - // get new host ids - args = []interface{}{} - parts := []string{} - for _, d := range devices { - args = append(args, d.SerialNumber) - parts = append(parts, "?") - } - var hostsWithEnrolled []hostWithEnrolled - err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` + var hostsWithEnrolled []struct { + fleet.Host + Enrolled *bool `db:"enrolled"` + } + err = sqlx.SelectContext(ctx, tx, &hostsWithEnrolled, fmt.Sprintf(` SELECT h.id, h.platform, h.hardware_model, h.hardware_serial, + h.hostname, COALESCE(hmdm.enrolled, 0) as enrolled FROM hosts h LEFT JOIN host_mdm hmdm ON hmdm.host_id = h.id WHERE h.hardware_serial IN(%s)`, - strings.Join(parts, ",")), - args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + strings.Join(parts, ",")), + args...) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get host ids") + } + + var hosts []fleet.Host + var unmanagedHostIDs []uint + for _, h := range hostsWithEnrolled { + hosts = append(hosts, h.Host) + if h.Enrolled == nil || !*h.Enrolled { + unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + } + } + + if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") + } + + if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, hosts...); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") + } + + appCfg, err := appConfigDB(ctx, tx) + if err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") + } + + // only upsert MDM info for hosts that are unmanaged. This + // prevents us from overriding valuable info with potentially + // incorrect data. For example: if a host is enrolled in a + // third-party MDM, but gets assigned in ABM to Fleet (during + // migration) we'll get an 'added' event. In that case, we + // expect that MDM info will be updated in due time as we ingest + // future osquery data from the host + if err := upsertMDMAppleHostMDMInfoDB( + ctx, + tx, + appCfg.ServerSettings, + fromADE, + unmanagedHostIDs..., + ); err != nil { + return 0, nil, ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + } + + return n, hosts, nil +} + +func (ds *Datastore) IngestMDMAppleDeviceFromOTAEnrollment( + ctx context.Context, + teamID *uint, + deviceInfo fleet.MDMAppleMachineInfo, +) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + toInsert := []hostToCreateFromMDM{ + { + HardwareSerial: deviceInfo.Serial, + PlatformHint: deviceInfo.Product, + HardwareModel: deviceInfo.Product, + }, + } + _, _, err := createHostFromMDMDB(ctx, tx, ds.logger, toInsert, false, teamID, teamID, teamID) + return ctxerr.Wrap(ctx, err, "creating host from OTA enrollment") + }) +} + +func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync( + ctx context.Context, + devices []godep.Device, + abmTokenID uint, + macOSTeam, iosTeam, ipadTeam *fleet.Team, +) (createdCount int64, err error) { + if len(devices) < 1 { + level.Debug(ds.logger).Log("msg", "ingesting devices from DEP received < 1 device, skipping", "len(devices)", len(devices)) + return 0, nil + } + + var teamIDs []*uint + for _, team := range []*fleet.Team{macOSTeam, iosTeam, ipadTeam} { + if team == nil { + teamIDs = append(teamIDs, nil) + continue } - var hosts []fleet.Host - var unmanagedHostIDs []uint - for _, h := range hostsWithEnrolled { - hosts = append(hosts, h.Host) - if h.Enrolled == nil || !*h.Enrolled { - unmanagedHostIDs = append(unmanagedHostIDs, h.ID) + exists, err := ds.TeamExists(ctx, team.ID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get team by name") + } + + if exists { + teamIDs = append(teamIDs, &team.ID) + continue + } + + // If the team doesn't exist, we still ingest the device, but it won't + // belong to any team. + level.Debug(ds.logger).Log( + "msg", + "ingesting devices from ABM: unable to find default team assigned in config, the devices won't be assigned to a team", + "team_id", + team, + ) + teamIDs = append(teamIDs, nil) + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + htc := make([]hostToCreateFromMDM, len(devices)) + for i, d := range devices { + htc[i] = hostToCreateFromMDM{ + HardwareSerial: d.SerialNumber, + HardwareModel: d.Model, + PlatformHint: d.DeviceFamily, } } - if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") - } - - if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, ds.logger, hosts...); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") - } - if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") - } - - // only upsert MDM info for hosts that are unmanaged. This - // prevents us from overriding valuable info with potentially - // incorrect data. For example: if a host is enrolled in a - // third-party MDM, but gets assigned in ABM to Fleet (during - // migration) we'll get an 'added' event. In that case, we - // expect that MDM info will be updated in due time as we ingest - // future osquery data from the host - if err := upsertMDMAppleHostMDMInfoDB( + n, hosts, err := createHostFromMDMDB( ctx, tx, - appCfg.ServerSettings, + ds.logger, + htc, true, - unmanagedHostIDs..., - ); err != nil { - return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") + teamIDs[0], teamIDs[1], teamIDs[2], + ) + if err != nil { + return err + } + createdCount = n + + if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { + return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert DEP assignments") } return nil }) - return createdCount, teamID, err + return createdCount, err } -func (ds *Datastore) UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []fleet.Host) error { +func (ds *Datastore) UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []fleet.Host, abmTokenID uint) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts); err != nil { + if err := upsertHostDEPAssignmentsDB(ctx, tx, hosts, abmTokenID); err != nil { return ctxerr.Wrap(ctx, err, "upsert host DEP assignments") } @@ -1048,13 +1135,13 @@ func (ds *Datastore) UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts }) } -func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts []fleet.Host) error { +func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts []fleet.Host, abmTokenID uint) error { if len(hosts) == 0 { return nil } stmt := ` - INSERT INTO host_dep_assignments (host_id) + INSERT INTO host_dep_assignments (host_id, abm_token_id) VALUES %s ON DUPLICATE KEY UPDATE added_at = CURRENT_TIMESTAMP, @@ -1063,8 +1150,8 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [ args := []interface{}{} values := []string{} for _, host := range hosts { - args = append(args, host.ID) - values = append(values, "(?)") + args = append(args, host.ID, abmTokenID) + values = append(values, "(?, ?)") } _, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, strings.Join(values, ",")), args...) @@ -1113,7 +1200,7 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, server } var mdmID int64 - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { mdmID, _ = result.LastInsertId() } else { stmt := `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?` @@ -1287,22 +1374,24 @@ func (ds *Datastore) MDMTurnOff(ctx context.Context, uuid string) error { }) } -func unionSelectDevices(devices []godep.Device) (stmt string, args []interface{}) { +func unionSelectDevices(devices []hostToCreateFromMDM) (stmt string, args []interface{}) { for i, d := range devices { if i == 0 { stmt = "SELECT ? hardware_serial, ? hardware_model, ? platform" } else { stmt += " UNION SELECT ?, ?, ?" } - // Map Apple's device family to Fleet's hosts.platform field. - platform := "darwin" - switch d.DeviceFamily { - case "iPhone": - platform = "ios" - case "iPad": - platform = "ipados" + + // map the platform hint to Fleet's hosts.platform field. + normalizedHint := strings.ToLower(d.PlatformHint) + platform := string(fleet.MacOSPlatform) + switch { + case strings.Contains(normalizedHint, "iphone"): + platform = string(fleet.IOSPlatform) + case strings.Contains(normalizedHint, "ipad"): + platform = string(fleet.IPadOSPlatform) } - args = append(args, d.SerialNumber, d.Model, platform) + args = append(args, d.HardwareSerial, d.HardwareModel, platform) } return stmt, args @@ -1311,7 +1400,7 @@ func unionSelectDevices(devices []godep.Device) (stmt string, args []interface{} func (ds *Datastore) GetHostDEPAssignment(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, error) { var res fleet.HostDEPAssignment err := sqlx.GetContext(ctx, ds.reader(ctx), &res, ` - SELECT host_id, added_at, deleted_at FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID) + SELECT host_id, added_at, deleted_at, abm_token_id FROM host_dep_assignments hdep WHERE hdep.host_id = ?`, hostID) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("HostDEPAssignment").WithID(hostID)) @@ -1422,7 +1511,8 @@ func (ds *Datastore) GetNanoMDMEnrollment(ctx context.Context, id string) (*flee func (ds *Datastore) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + _, err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + return err }) } @@ -1432,7 +1522,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMAppleConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT identifier, @@ -1494,13 +1584,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1521,31 +1611,37 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepIdents or delivered by Fleet) + var result sql.Result stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, append(keepIdents, fleetIdents...)) if err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, + p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1561,19 +1657,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Identifier] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1589,13 +1685,15 @@ ON DUPLICATE KEY UPDATE } // insert label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting apple profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting apple profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) BulkDeleteMDMAppleHostsConfigProfiles(ctx context.Context, profs []*fleet.MDMAppleProfilePayload) error { @@ -1660,9 +1758,9 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } appleMDMProfilesDesiredStateQuery := generateDesiredStateQuery("profile") @@ -1708,18 +1806,39 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( ( hmap.host_uuid IS NOT NULL AND ( hmap.operation_type = ? OR hmap.operation_type IS NULL ) ) `, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) - // TODO: if a very large number (~65K) of host uuids was matched (via - // uuids, teams or profile IDs), could result in too many placeholders (not - // an immediate concern). - stmt, args, err := sqlx.In(toInstallStmt, uuids, uuids, uuids, fleet.MDMOperationTypeRemove) - if err != nil { - return ctxerr.Wrap(ctx, err, "building profiles to install statement") + // batches of 10K hosts because h.uuid appears three times in the + // query, and the max number of prepared statements is 65K, this was + // good enough during a load test and gives us wiggle room if we add + // more arguments and we forget to update the batch size. + selectProfilesBatchSize := 10_000 + if ds.testSelectMDMProfilesBatchSize > 0 { + selectProfilesBatchSize = ds.testSelectMDMProfilesBatchSize } + selectProfilesTotalBatches := int(math.Ceil(float64(len(uuids)) / float64(selectProfilesBatchSize))) var wantedProfiles []*fleet.MDMAppleProfilePayload - err = sqlx.SelectContext(ctx, tx, &wantedProfiles, stmt, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute") + for i := 0; i < selectProfilesTotalBatches; i++ { + start := i * selectProfilesBatchSize + end := start + selectProfilesBatchSize + if end > len(uuids) { + end = len(uuids) + } + + batchUUIDs := uuids[start:end] + + stmt, args, err := sqlx.In(toInstallStmt, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) + if err != nil { + return false, ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, + selectProfilesTotalBatches) + } + + var partialResult []*fleet.MDMAppleProfilePayload + err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) + if err != nil { + return false, ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches) + } + + wantedProfiles = append(wantedProfiles, partialResult...) } // Exclude macOS only profiles from iPhones/iPads. @@ -1756,28 +1875,44 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( ) `, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) - // TODO: if a very large number (~65K) of host uuids was matched (via - // uuids, teams or profile IDs), could result in too many placeholders (not - // an immediate concern). Note that uuids are provided twice. - stmt, args, err = sqlx.In(toRemoveStmt, uuids, uuids, uuids, uuids, fleet.MDMOperationTypeRemove) - if err != nil { - return ctxerr.Wrap(ctx, err, "building profiles to remove statement") - } var currentProfiles []*fleet.MDMAppleProfilePayload - err = sqlx.SelectContext(ctx, tx, ¤tProfiles, stmt, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "fetching profiles to remove") + for i := 0; i < selectProfilesTotalBatches; i++ { + start := i * selectProfilesBatchSize + end := start + selectProfilesBatchSize + if end > len(uuids) { + end = len(uuids) + } + + batchUUIDs := uuids[start:end] + + stmt, args, err := sqlx.In(toRemoveStmt, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "building profiles to remove statement") + } + var partialResult []*fleet.MDMAppleProfilePayload + err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "fetching profiles to remove") + } + + currentProfiles = append(currentProfiles, partialResult...) } if len(wantedProfiles) == 0 && len(currentProfiles) == 0 { - return nil + return false, nil } // delete all host profiles to start from a clean slate, new entries will be added next // TODO(roberto): is this really necessary? this was pre-existing // behavior but I think it can be refactored. For now leaving it as-is. - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete all profiles") + // + // TODO part II(roberto): we found this call to be a major bottleneck during load testing + // https://github.com/fleetdm/fleet/issues/21338 + if len(wantedProfiles) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete all profiles") + } + updatedDB = true } // profileIntersection tracks profilesToAdd ∩ profilesToRemove, this is used to avoid: @@ -1798,11 +1933,57 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( hostProfilesToClean = append(hostProfilesToClean, p) } } - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + if len(hostProfilesToClean) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + } + updatedDB = true } + profilesToInsert := make(map[string]*fleet.MDMAppleProfilePayload) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + profile_uuid, + profile_identifier, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + command_uuid, + profile_name, + checksum, + profile_uuid + FROM host_mdm_apple_profiles WHERE (host_uuid, profile_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.ProfileUUID) + } + var existingProfiles []fleet.MDMAppleProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.ProfileUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_apple_profiles ( profile_uuid, @@ -1842,6 +2023,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( resetBatch := func() { batchCount = 0 + clear(profilesToInsert) pargs = pargs[:0] psb.Reset() } @@ -1849,6 +2031,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( for _, p := range wantedProfiles { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) { + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + Status: pp.Status, + OperationType: pp.OperationType, + Detail: pp.Detail, + CommandUUID: pp.CommandUUID, + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, pp.OperationType, pp.Status, pp.CommandUUID, pp.Detail) psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1856,7 +2050,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1864,6 +2058,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( } } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeInstall, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeInstall, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1871,7 +2077,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1881,13 +2087,25 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if _, ok := profileIntersection.GetMatchingProfileInDesiredState(p); ok { continue } - // If the installation failed, then we do not want to change the operation to "Remove". + // If the profile wasn't installed, then we do not want to change the operation to "Remove". // Doing so will result in Fleet attempting to remove a profile that doesn't exist on the // host (since the installation failed). Skipping it here will lead to it being removed from // the host in Fleet during profile reconciliation, which is what we want. - if p.FailedToInstallOnHost() { + if p.DidNotInstallOnHost() { continue } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeRemove, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeRemove, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1895,7 +2113,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1903,10 +2121,10 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } // mdmEntityTypeToDynamicNames tracks what names should be used in the @@ -2960,21 +3178,74 @@ func (ds *Datastore) BulkUpsertMDMAppleConfigProfiles(ctx context.Context, paylo return nil } -func (ds *Datastore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { - stmt := ` - INSERT INTO mdm_apple_bootstrap_packages (team_id, name, sha256, bytes, token) - VALUES (?, ?, ?, ?, ?) - ` - - _, err := ds.writer(ctx).ExecContext(ctx, stmt, bp.TeamID, bp.Name, bp.Sha256, bp.Bytes, bp.Token) - if err != nil { - if IsDuplicate(err) { - return ctxerr.Wrap(ctx, alreadyExists("BootstrapPackage", fmt.Sprintf("for team %d", bp.TeamID))) +func isMDMAppleBootstrapPackageInDB(ctx context.Context, q sqlx.QueryerContext, teamID uint) (isInDB, existsForTeam bool, err error) { + const stmt = `SELECT COALESCE(LENGTH(bytes), 0) FROM mdm_apple_bootstrap_packages WHERE team_id = ?` + var pkgLen int + if err := sqlx.GetContext(ctx, q, &pkgLen, stmt, teamID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil } - return ctxerr.Wrap(ctx, err, "create bootstrap package") + return false, false, ctxerr.Wrapf(ctx, err, "check for bootstrap package content in database for team %d", teamID) + } + return pkgLen > 0, true, nil +} + +func (ds *Datastore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { + const insStmt = `INSERT INTO mdm_apple_bootstrap_packages (team_id, name, sha256, bytes, token) VALUES (?, ?, ?, ?, ?)` + execInsert := func(args ...any) error { + if _, err := ds.writer(ctx).ExecContext(ctx, insStmt, args...); err != nil { + if IsDuplicate(err) { + return ctxerr.Wrap(ctx, alreadyExists("BootstrapPackage", fmt.Sprintf("for team %d", bp.TeamID))) + } + return ctxerr.Wrap(ctx, err, "create bootstrap package") + } + return nil } - return nil + if pkgStore == nil { + // no S3 storage configured, insert the metadata and the content in the DB + return execInsert(bp.TeamID, bp.Name, bp.Sha256, bp.Bytes, bp.Token) + } + + // using distinct storages for content and metadata introduces an + // intractable problem: the operation cannot be atomic (all succeed or all + // fail together), so what we do instead is to minimize the risk of data + // inconsistency: + // + // 1. check if the row exists in the DB, if so fail immediately with a + // duplicate error (which would happen at the INSERT stage anyway + // otherwise). + // 2. if it does not exist in the DB, check if the package is already on + // S3, to avoid a costly upload if it is. + // 3. if it is not already on S3, upload the package - if this fails, + // return and the DB was not touched and data is still consistent. + // 4. after upload, insert the metadata in the DB - if this fails, the + // only possible inconsistency is an unused package stored on S3, which a + // cron job will eventually cleanup. + // 5. if everything succeeds, data is consistent and the S3 package + // cannot be used before it is uploaded (since the DB row is inserted + // after upload). + _, existsInDB, err := isMDMAppleBootstrapPackageInDB(ctx, ds.writer(ctx), bp.TeamID) + if err != nil { + return err + } + if existsInDB { + return ctxerr.Wrap(ctx, alreadyExists("BootstrapPackage", fmt.Sprintf("for team %d", bp.TeamID))) + } + + pkgID := hex.EncodeToString(bp.Sha256) + ok, err := pkgStore.Exists(ctx, pkgID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "check if bootstrap package %s already exists", pkgID) + } + if !ok { + if err := pkgStore.Put(ctx, pkgID, bytes.NewReader(bp.Bytes)); err != nil { + return ctxerr.Wrapf(ctx, err, "upload bootstrap package %s to S3", pkgID) + } + } + + // insert in the DB with a NULL bytes content (to indicate it is on S3) + return execInsert(bp.TeamID, bp.Name, bp.Sha256, nil, bp.Token) } func (ds *Datastore) CopyDefaultMDMAppleBootstrapPackage(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { @@ -2985,6 +3256,9 @@ func (ds *Datastore) CopyDefaultMDMAppleBootstrapPackage(ctx context.Context, ac return ctxerr.New(ctx, "team id must not be zero") } + // NOTE: if the bootstrap package is stored in S3, nothing needs to happen on + // S3 for a copy of it since the bytes are the same and the stored contents + // is the same (the sha256 is copied, so it points to the same file on S3). return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // Copy the bytes for the default bootstrap package to the specified team insertStmt := ` @@ -3029,6 +3303,12 @@ WHERE id = ? } func (ds *Datastore) DeleteMDMAppleBootstrapPackage(ctx context.Context, teamID uint) error { + // NOTE: if S3 storage is used for the bootstrap package, we don't delete it + // here. The reason for this is that other teams may be using the same + // package, so it would use the same S3 key (based on its hash). Instead we + // rely on the cron job to clear unused packages from S3. Outside of using up + // space in the bucket, an unused package on S3 is not a problem. + stmt := "DELETE FROM mdm_apple_bootstrap_packages WHERE team_id = ?" res, err := ds.writer(ctx).ExecContext(ctx, stmt, teamID) if err != nil { @@ -3042,8 +3322,9 @@ func (ds *Datastore) DeleteMDMAppleBootstrapPackage(ctx context.Context, teamID return nil } -func (ds *Datastore) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*fleet.MDMAppleBootstrapPackage, error) { - stmt := "SELECT name, bytes FROM mdm_apple_bootstrap_packages WHERE token = ?" +func (ds *Datastore) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string, pkgStore fleet.MDMBootstrapPackageStore) (*fleet.MDMAppleBootstrapPackage, error) { + const stmt = `SELECT name, bytes, sha256 FROM mdm_apple_bootstrap_packages WHERE token = ?` + var bp fleet.MDMAppleBootstrapPackage if err := sqlx.GetContext(ctx, ds.reader(ctx), &bp, stmt, token); err != nil { if err == sql.ErrNoRows { @@ -3051,6 +3332,24 @@ func (ds *Datastore) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token } return nil, ctxerr.Wrap(ctx, err, "get bootstrap package bytes") } + + if pkgStore != nil && len(bp.Bytes) == 0 { + // bootstrap package is stored on S3, retrieve it + pkgID := hex.EncodeToString(bp.Sha256) + rc, _, err := pkgStore.Get(ctx, pkgID) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "get bootstrap package %s from S3", pkgID) + } + defer rc.Close() + + // TODO: optimize memory usage by supporting a streaming approach + // throughout the API (we have a similar issue with software installers). + // Currently we load everything in memory and those can be quite big. + bp.Bytes, err = io.ReadAll(rc) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "reading bootstrap package %s from S3", pkgID) + } + } return &bp, nil } @@ -3153,6 +3452,27 @@ func (ds *Datastore) GetMDMAppleBootstrapPackageMeta(ctx context.Context, teamID return &bp, nil } +func (ds *Datastore) CleanupUnusedBootstrapPackages(ctx context.Context, pkgStore fleet.MDMBootstrapPackageStore, removeCreatedBefore time.Time) error { + if pkgStore == nil { + // no-op in this case, possible if not running with a Premium license or + // configured S3 storage + return nil + } + + // get the list of bootstrap package hashes that are in use + var shaIDs [][]byte + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &shaIDs, `SELECT DISTINCT sha256 FROM mdm_apple_bootstrap_packages`); err != nil { + return ctxerr.Wrap(ctx, err, "get list of bootstrap packages in use") + } + var pkgIDs []string + for _, sha := range shaIDs { + pkgIDs = append(pkgIDs, hex.EncodeToString(sha)) + } + + _, err := pkgStore.Cleanup(ctx, pkgIDs, removeCreatedBefore) + return ctxerr.Wrap(ctx, err, "cleanup unused bootstrap packages") +} + func (ds *Datastore) CleanupDiskEncryptionKeysOnTeamChange(ctx context.Context, hostIDs []uint, newTeamID *uint) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { return cleanupDiskEncryptionKeysOnTeamChangeDB(ctx, tx, hostIDs, newTeamID) @@ -3230,7 +3550,6 @@ func (ds *Datastore) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst (?, ?, ?, ?) ON DUPLICATE KEY UPDATE updated_at = IF(profile = VALUES(profile) AND name = VALUES(name), updated_at, CURRENT_TIMESTAMP), - profile_uuid = IF(profile = VALUES(profile) AND name = VALUES(name), profile_uuid, ''), name = VALUES(name), profile = VALUES(profile) ` @@ -3238,41 +3557,116 @@ func (ds *Datastore) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst if asst.TeamID != nil { globalOrTmID = *asst.TeamID } - _, err := ds.writer(ctx).ExecContext(ctx, stmt, asst.TeamID, globalOrTmID, asst.Name, asst.Profile) + res, err := ds.writer(ctx).ExecContext(ctx, stmt, asst.TeamID, globalOrTmID, asst.Name, asst.Profile) if err != nil { return nil, ctxerr.Wrap(ctx, err, "upsert mdm apple setup assistant") } + // TODO(mna): to improve, previously we only cleared the profile UUIDs if the + // profile/name did change, but from tests it seems we can't rely on + // insertOnDuplicateDidUpdate to handle this case properly (presumably + // because the updated_at update condition is too complex?), so at the moment + // this clears the profile uuids at all times, even if the profile did not + // change. + if insertOnDuplicateDidInsertOrUpdate(res) { + // profile was updated, need to clear the profile uuids + if err := ds.SetMDMAppleSetupAssistantProfileUUID(ctx, asst.TeamID, "", ""); err != nil { + return nil, ctxerr.Wrap(ctx, err, "clear mdm apple setup assistant profiles") + } + } + // reload to return the proper timestamp and id return ds.getMDMAppleSetupAssistant(ctx, ds.writer(ctx), asst.TeamID) } -func (ds *Datastore) SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error { - const stmt = ` - UPDATE - mdm_apple_setup_assistants - SET - profile_uuid = ?, - -- ensure updated_at does not change, as it is used to reflect the time - -- the setup assistant was uploaded, not when its profile was defined - -- with Apple's API. - updated_at = updated_at - WHERE global_or_team_id = ?` +func (ds *Datastore) SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID, abmTokenOrgName string) error { + const clearStmt = ` + DELETE FROM mdm_apple_setup_assistant_profiles + WHERE setup_assistant_id = ( + SELECT + id + FROM + mdm_apple_setup_assistants + WHERE + global_or_team_id = ? + )` + + const upsertStmt = ` + INSERT INTO mdm_apple_setup_assistant_profiles ( + setup_assistant_id, abm_token_id, profile_uuid + ) ( + SELECT + mas.id, abt.id, ? + FROM + mdm_apple_setup_assistants mas, + abm_tokens abt + WHERE + mas.global_or_team_id = ? AND + abt.organization_name = ? AND + mas.id IS NOT NULL AND + abt.id IS NOT NULL + ) + ON DUPLICATE KEY UPDATE + profile_uuid = VALUES(profile_uuid) + ` var globalOrTmID uint if teamID != nil { globalOrTmID = *teamID } - res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, globalOrTmID) + + if profileUUID == "" && abmTokenOrgName == "" { + // delete all profile uuids for that team, regardless of ABM token + _, err := ds.writer(ctx).ExecContext(ctx, clearStmt, globalOrTmID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete mdm apple setup assistant profiles") + } + return nil + } + + _, err := ds.writer(ctx).ExecContext(ctx, upsertStmt, profileUUID, globalOrTmID, abmTokenOrgName) if err != nil { return ctxerr.Wrap(ctx, err, "set mdm apple setup assistant profile uuid") } - if n, _ := res.RowsAffected(); n == 0 { - return ctxerr.Wrap(ctx, notFound("MDMAppleSetupAssistant").WithID(globalOrTmID)) - } return nil } +func (ds *Datastore) GetMDMAppleSetupAssistantProfileForABMToken(ctx context.Context, teamID *uint, abmTokenOrgName string) (string, time.Time, error) { + // to preserve previous behavior, the updated_at we use is the one of the + // setup assistant (what we refer to as "uploaded_at" in the API responses), + // as the important signal is when the content of the setup assistant + // changes. + const stmt = ` + SELECT + msap.profile_uuid, + mas.updated_at + FROM + mdm_apple_setup_assistants mas + INNER JOIN + mdm_apple_setup_assistant_profiles msap ON mas.id = msap.setup_assistant_id + INNER JOIN + abm_tokens abt ON abt.id = msap.abm_token_id + WHERE + mas.global_or_team_id = ? AND + abt.organization_name = ? +` + var globalOrTmID uint + if teamID != nil { + globalOrTmID = *teamID + } + var asstProf struct { + ProfileUUID string `db:"profile_uuid"` + UpdatedAt time.Time `db:"updated_at"` + } + if err := sqlx.GetContext(ctx, ds.writer(ctx) /* needs to read recent writes */, &asstProf, stmt, globalOrTmID, abmTokenOrgName); err != nil { + if err == sql.ErrNoRows { + return "", time.Time{}, ctxerr.Wrap(ctx, notFound("MDMAppleSetupAssistant").WithID(globalOrTmID)) + } + return "", time.Time{}, ctxerr.Wrap(ctx, err, "get mdm apple setup assistant") + } + return asstProf.ProfileUUID, asstProf.UpdatedAt, nil +} + func (ds *Datastore) GetMDMAppleSetupAssistant(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { return ds.getMDMAppleSetupAssistant(ctx, ds.reader(ctx), teamID) } @@ -3284,7 +3678,6 @@ func (ds *Datastore) getMDMAppleSetupAssistant(ctx context.Context, q sqlx.Query team_id, name, profile, - profile_uuid, updated_at as uploaded_at FROM mdm_apple_setup_assistants @@ -3305,6 +3698,8 @@ func (ds *Datastore) getMDMAppleSetupAssistant(ctx context.Context, q sqlx.Query } func (ds *Datastore) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *uint) error { + // this deletes the setup assistant for that team, and via foreign key + // cascade also the profiles associated with it. const stmt = ` DELETE FROM mdm_apple_setup_assistants WHERE global_or_team_id = ?` @@ -3377,12 +3772,20 @@ WHERE return serials, nil } -func (ds *Datastore) SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error { - const stmt = ` +func (ds *Datastore) SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID, abmTokenOrgName string) error { + const clearStmt = ` + DELETE FROM mdm_apple_default_setup_assistants + WHERE global_or_team_id = ?` + + const upsertStmt = ` INSERT INTO - mdm_apple_default_setup_assistants (team_id, global_or_team_id, profile_uuid) - VALUES - (?, ?, ?) + mdm_apple_default_setup_assistants (team_id, global_or_team_id, profile_uuid, abm_token_id) + SELECT + ?, ?, ?, abt.id + FROM + abm_tokens abt + WHERE + abt.organization_name = ? ON DUPLICATE KEY UPDATE profile_uuid = VALUES(profile_uuid) ` @@ -3390,34 +3793,53 @@ func (ds *Datastore) SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Con if teamID != nil { globalOrTmID = *teamID } - _, err := ds.writer(ctx).ExecContext(ctx, stmt, teamID, globalOrTmID, profileUUID) + + if profileUUID == "" && abmTokenOrgName == "" { + // delete all profile uuids for that team, regardless of ABM token + _, err := ds.writer(ctx).ExecContext(ctx, clearStmt, globalOrTmID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete mdm apple default setup assistant") + } + return nil + } + + // upsert the profile uuid for the provided token + _, err := ds.writer(ctx).ExecContext(ctx, upsertStmt, teamID, globalOrTmID, profileUUID, abmTokenOrgName) if err != nil { return ctxerr.Wrap(ctx, err, "upsert mdm apple default setup assistant") } return nil } -func (ds *Datastore) GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint) (profileUUID string, updatedAt time.Time, err error) { +func (ds *Datastore) GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint, abmTokenOrgName string) (profileUUID string, updatedAt time.Time, err error) { const stmt = ` SELECT - profile_uuid, - updated_at as uploaded_at + mad.profile_uuid, + mad.updated_at FROM - mdm_apple_default_setup_assistants - WHERE global_or_team_id = ?` + mdm_apple_default_setup_assistants mad + INNER JOIN + abm_tokens abt ON mad.abm_token_id = abt.id + WHERE + mad.global_or_team_id = ? AND + abt.organization_name = ? + ` var globalOrTmID uint if teamID != nil { globalOrTmID = *teamID } - var asst fleet.MDMAppleSetupAssistant - if err := sqlx.GetContext(ctx, ds.writer(ctx) /* needs to read recent writes */, &asst, stmt, globalOrTmID); err != nil { + var asstProf struct { + ProfileUUID string `db:"profile_uuid"` + UpdatedAt time.Time `db:"updated_at"` + } + if err := sqlx.GetContext(ctx, ds.writer(ctx) /* needs to read recent writes */, &asstProf, stmt, globalOrTmID, abmTokenOrgName); err != nil { if err == sql.ErrNoRows { return "", time.Time{}, ctxerr.Wrap(ctx, notFound("MDMAppleDefaultSetupAssistant").WithID(globalOrTmID)) } return "", time.Time{}, ctxerr.Wrap(ctx, err, "get mdm apple default setup assistant") } - return asst.ProfileUUID, asst.UploadedAt, nil + return asstProf.ProfileUUID, asstProf.UpdatedAt, nil } func (ds *Datastore) UpdateHostDEPAssignProfileResponses(ctx context.Context, payload *godep.ProfileResponse) error { @@ -3499,9 +3921,9 @@ WHERE // 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) { +func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) { if len(serials) == 0 { - return skipSerials, assignSerials, nil + return skipSerialsByOrgName, serialsByOrgName, nil } stmt := ` @@ -3511,12 +3933,14 @@ SELECT ELSE 'assign' END AS status, - hardware_serial + h.hardware_serial, + abm.organization_name FROM - host_dep_assignments - JOIN hosts ON id = host_id + host_dep_assignments hda + JOIN hosts h ON h.id = hda.host_id + JOIN abm_tokens abm ON abm.id = hda.abm_token_id WHERE - hardware_serial IN (?) + h.hardware_serial IN (?) ` stmt, args, err := sqlx.In(stmt, string(fleet.DEPAssignProfileResponseFailed), depCooldownPeriod.Seconds(), serials) @@ -3527,23 +3951,27 @@ WHERE var rows []struct { Status string `db:"status"` HardwareSerial string `db:"hardware_serial"` + ABMOrgName string `db:"organization_name"` } 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") } + serialsByOrgName = make(map[string][]string) + skipSerialsByOrgName = make(map[string][]string) + for _, r := range rows { switch r.Status { case "assign": - assignSerials = append(assignSerials, r.HardwareSerial) + serialsByOrgName[r.ABMOrgName] = append(serialsByOrgName[r.ABMOrgName], r.HardwareSerial) case "skip": - skipSerials = append(skipSerials, r.HardwareSerial) + skipSerialsByOrgName[r.ABMOrgName] = append(skipSerialsByOrgName[r.ABMOrgName], 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 + return skipSerialsByOrgName, serialsByOrgName, nil } func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) { @@ -3690,7 +4118,9 @@ WHERE h.uuid = ? return nil } -func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, incomingDeclarations []*fleet.MDMAppleDeclaration) ([]*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, + incomingDeclarations []*fleet.MDMAppleDeclaration, +) (declarations []*fleet.MDMAppleDeclaration, updatedDB bool, err error) { const insertStmt = ` INSERT INTO mdm_apple_declarations ( declaration_uuid, @@ -3757,13 +4187,13 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "build query to load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load existing declarations") } if err := sqlx.SelectContext(ctx, tx, &existingDecls, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load existing declarations") } } @@ -3786,23 +4216,29 @@ WHERE // delete the obsolete declarations (all those that are not in keepNames) stmt, args, err := sqlx.In(fmt.Sprintf(fmtDeleteStmt, andNameNotInList), declTeamID, keepNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") + return nil, false, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") } delStmt = stmt delArgs = args } - if _, err := tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + var result sql.Result + if result, err = tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + return nil, false, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } for _, d := range incomingDeclarations { checksum := md5ChecksumScriptContent(string(d.RawJSON)) declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString() - if _, err := tx.ExecContext(ctx, insertStmt, + if result, err = tx.ExecContext(ctx, insertStmt, declUUID, d.Identifier, d.Name, @@ -3812,8 +4248,9 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) + return nil, false, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } incomingLabels := []fleet.ConfigurationProfileLabel{} @@ -3828,16 +4265,16 @@ WHERE // optimization for a later iteration. stmt, args, err := sqlx.In(loadExistingDecls, declTeamID, incomingNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedDecls, stmt, args...); err != nil { - return nil, ctxerr.Wrap(ctx, err, "load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load newly inserted declarations") } for _, newlyInsertedDecl := range newlyInsertedDecls { incomingDecl, ok := incomingDecls[newlyInsertedDecl.Name] if !ok { - return nil, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) + return nil, false, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) } for _, label := range incomingDecl.LabelsIncludeAll { @@ -3852,14 +4289,16 @@ WHERE } } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetDeclarationLabelAssociationsDB(ctx, tx, + incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") + return nil, false, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") } - return incomingDeclarations, nil + return incomingDeclarations, updatedDB || updatedLabels, nil } func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { @@ -3956,7 +4395,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO declaration.LabelsExcludeAny[i].Exclude = true labels = append(labels, declaration.LabelsExcludeAny[i]) } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { + if _, err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations") } @@ -3970,9 +4409,11 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO return declaration, nil } -func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, declarationLabels []fleet.ConfigurationProfileLabel) error { +func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, + declarationLabels []fleet.ConfigurationProfileLabel, +) (updatedDB bool, err error) { if len(declarationLabels) == 0 { - return nil + return false, nil } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -3994,38 +4435,72 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont exclude = VALUES(exclude) ` + selectStmt := ` + SELECT apple_declaration_uuid as profile_uuid, label_name, label_id, exclude FROM mdm_declaration_labels + WHERE (apple_declaration_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) + labelsToInsert = make(map[string]*fleet.ConfigurationProfileLabel, len(declarationLabels)) ) for i, pl := range declarationLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &declarationLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) - } - - return ctxerr.Wrap(ctx, err, "setting label associations for declarations") + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") } - deleteStmt = fmt.Sprintf(deleteStmt, deleteBuilder.String()) + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) + } + + return false, ctxerr.Wrap(ctx, err, "setting label associations for declarations") + } + updatedDB = true + } + + deleteStmt = fmt.Sprintf(deleteStmt, selectOrDeleteBuilder.String()) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -4035,13 +4510,21 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for declarations") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for declarations") + } + if result != nil { + rows, err := result.RowsAffected() + if err != nil { + return false, ctxerr.Wrap(ctx, err, "count rows affected by insert") + } + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) { @@ -4128,23 +4611,25 @@ func (ds *Datastore) MDMAppleBatchSetHostDeclarationState(ctx context.Context) ( err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error - uuids, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) + uuids, _, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) return err }) return uuids, ctxerr.Wrap(ctx, err, "upserting host declaration state") } -func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, status *fleet.MDMDeliveryStatus) ([]string, error) { +func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, + status *fleet.MDMDeliveryStatus, +) ([]string, bool, error) { // once all the declarations are in place, compute the desired state // and find which hosts need a DDM sync. changedDeclarations, err := mdmAppleGetHostsWithChangedDeclarationsDB(ctx, tx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") + return nil, false, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") } if len(changedDeclarations) == 0 { - return []string{}, nil + return []string{}, false, nil } // a host might have more than one declaration to sync, we do this to @@ -4166,11 +4651,12 @@ func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtCont // - support the DDM endpoints, which use data from the // `host_mdm_apple_declarations` table to compute which declarations to // serve - if err := mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { - return nil, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") + var updatedDB bool + if updatedDB, err = mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") } - return uuids, nil + return uuids, updatedDB, nil } // mdmAppleBatchSetPendingHostDeclarationsDB tracks the current status of all @@ -4181,7 +4667,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( batchSize int, changedDeclarations []*fleet.MDMAppleHostDeclaration, status *fleet.MDMDeliveryStatus, -) error { +) (updatedDB bool, err error) { baseStmt := ` INSERT INTO host_mdm_apple_declarations (host_uuid, status, operation_type, checksum, declaration_uuid, declaration_identifier, declaration_name) @@ -4193,7 +4679,50 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( checksum = VALUES(checksum) ` + profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + declaration_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + checksum, + declaration_uuid, + declaration_identifier, + declaration_name + FROM host_mdm_apple_declarations WHERE (host_uuid, declaration_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.DeclarationUUID) + } + var existingProfiles []fleet.MDMAppleHostDeclaration + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending declarations select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.DeclarationUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + clear(profilesToInsert) + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true _, err := tx.ExecContext( ctx, fmt.Sprintf(baseStmt, strings.TrimSuffix(valuePart, ",")), @@ -4203,13 +4732,23 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( } generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) { + profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{ + HostUUID: d.HostUUID, + DeclarationUUID: d.DeclarationUUID, + Name: d.Name, + Identifier: d.Identifier, + Status: status, + OperationType: d.OperationType, + Detail: d.Detail, + Checksum: d.Checksum, + } valuePart := "(?, ?, ?, ?, ?, ?, ?)," args := []any{d.HostUUID, status, d.OperationType, d.Checksum, d.DeclarationUUID, d.Identifier, d.Name} return valuePart, args } - err := batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) - return ctxerr.Wrap(ctx, err, "inserting changed host declaration state") + err = batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) + return updatedDB, ctxerr.Wrap(ctx, err, "inserting changed host declaration state") } // mdmAppleGetHostsWithChangedDeclarationsDB returns a @@ -4571,19 +5110,22 @@ func (ds *Datastore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet. // ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched // (their details haven't been updated in the given `interval`). -func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval time.Duration) (uuids []string, err error) { - var deviceUUIDs []string +func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval time.Duration) (devices []fleet.AppleDevicesToRefetch, + err error, +) { hostsStmt := fmt.Sprintf(` -SELECT h.uuid FROM hosts h -JOIN host_mdm hmdm ON hmdm.host_id = h.id +SELECT h.id as host_id, h.uuid as uuid, JSON_ARRAYAGG(hmc.command_type) as commands_already_sent FROM hosts h +INNER JOIN host_mdm hmdm ON hmdm.host_id = h.id +LEFT JOIN host_mdm_commands hmc ON hmc.host_id = h.id WHERE (h.platform = 'ios' OR h.platform = 'ipados') -AND hmdm.enrolled -AND TIMESTAMPDIFF(SECOND, h.detail_updated_at, NOW()) > ?;`) - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &deviceUUIDs, hostsStmt, interval.Seconds()); err != nil { +AND TRIM(h.uuid) != '' +AND TIMESTAMPDIFF(SECOND, h.detail_updated_at, NOW()) > ? +GROUP BY h.id`) + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &devices, hostsStmt, interval.Seconds()); err != nil { return nil, err } - return deviceUUIDs, nil + return devices, nil } func (ds *Datastore) GetHostUUIDsWithPendingMDMAppleCommands(ctx context.Context) (uuids []string, err error) { @@ -4603,3 +5145,490 @@ LIMIT 500 return deviceUUIDs, nil } + +func (ds *Datastore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { + tok, err := ds.getABMToken(ctx, 0, orgName) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get ABM token by org name") + } + + return tok, nil +} + +func (ds *Datastore) SaveABMToken(ctx context.Context, tok *fleet.ABMToken) error { + const stmt = ` +UPDATE + abm_tokens +SET + organization_name = ?, + apple_id = ?, + terms_expired = ?, + renew_at = ?, + token = ?, + macos_default_team_id = ?, + ios_default_team_id = ?, + ipados_default_team_id = ? +WHERE + id = ?` + + doubleEncTok, err := encrypt(tok.EncryptedToken, ds.serverPrivateKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "encrypt with datastore.serverPrivateKey") + } + + _, err = ds.writer(ctx).ExecContext( + ctx, + stmt, + tok.OrganizationName, + tok.AppleID, + tok.TermsExpired, + tok.RenewAt.UTC(), + doubleEncTok, + tok.MacOSDefaultTeamID, + tok.IOSDefaultTeamID, + tok.IPadOSDefaultTeamID, + tok.ID) + return ctxerr.Wrap(ctx, err, "updating abm_token") +} + +func (ds *Datastore) InsertABMToken(ctx context.Context, tok *fleet.ABMToken) (*fleet.ABMToken, error) { + const stmt = ` +INSERT INTO + abm_tokens + (organization_name, apple_id, terms_expired, renew_at, token, macos_default_team_id, ios_default_team_id, ipados_default_team_id) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + doubleEncTok, err := encrypt(tok.EncryptedToken, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "encrypt abm_token with datastore.serverPrivateKey") + } + + res, err := ds.writer(ctx).ExecContext( + ctx, + stmt, + tok.OrganizationName, + tok.AppleID, + tok.TermsExpired, + tok.RenewAt, + doubleEncTok, + tok.MacOSDefaultTeamID, + tok.IOSDefaultTeamID, + tok.IPadOSDefaultTeamID, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "inserting abm_token") + } + + tokenID, _ := res.LastInsertId() + + tok.ID = uint(tokenID) + + cfg, err := ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get app config") + } + + url, err := apple_mdm.ResolveAppleMDMURL(cfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting ABM token MDM server url") + } + + tok.MDMServerURL = url + + return tok, nil +} + +func (ds *Datastore) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error) { + stmt := ` +SELECT + abt.id, + abt.organization_name, + abt.apple_id, + abt.terms_expired, + abt.renew_at, + abt.token, + abt.macos_default_team_id, + abt.ios_default_team_id, + abt.ipados_default_team_id, + COALESCE(t1.name, :no_team) as macos_team, + COALESCE(t2.name, :no_team) as ios_team, + COALESCE(t3.name, :no_team) as ipados_team +FROM + abm_tokens abt +LEFT OUTER JOIN + teams t1 ON t1.id = abt.macos_default_team_id +LEFT OUTER JOIN + teams t2 ON t2.id = abt.ios_default_team_id +LEFT OUTER JOIN + teams t3 ON t3.id = abt.ipados_default_team_id + + ` + + stmt, args, err := sqlx.Named(stmt, map[string]any{"no_team": fleet.TeamNameNoTeam}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build list ABM tokens query from named args") + } + + var tokens []*fleet.ABMToken + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &tokens, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "list ABM tokens") + } + + cfg, err := ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get app config") + } + + url, err := apple_mdm.ResolveAppleMDMURL(cfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting ABM token MDM server url") + } + + for _, tok := range tokens { + tok.MDMServerURL = url + + // Promote DB fields into respective objects + var macOSTeamID, iOSTeamID, iPadIOSTeamID uint + if tok.MacOSDefaultTeamID != nil { + macOSTeamID = *tok.MacOSDefaultTeamID + } + if tok.IOSDefaultTeamID != nil { + iOSTeamID = *tok.IOSDefaultTeamID + } + if tok.IPadOSDefaultTeamID != nil { + iPadIOSTeamID = *tok.IPadOSDefaultTeamID + } + + tok.MacOSTeam = fleet.ABMTokenTeam{Name: tok.MacOSTeamName, ID: macOSTeamID} + tok.IOSTeam = fleet.ABMTokenTeam{Name: tok.IOSTeamName, ID: iOSTeamID} + tok.IPadOSTeam = fleet.ABMTokenTeam{Name: tok.IPadOSTeamName, ID: iPadIOSTeamID} + + // decrypt the token with the serverPrivateKey, the resulting value will be + // the token still encrypted, but just with the ABM cert and key (it is that + // encrypted value that is stored with another layer of encryption with the + // serverPrivateKey). + decrypted, err := decrypt(tok.EncryptedToken, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "decrypting abm token with datastore.serverPrivateKey") + } + tok.EncryptedToken = decrypted + } + + return tokens, nil +} + +func (ds *Datastore) DeleteABMToken(ctx context.Context, tokenID uint) error { + const stmt = ` +DELETE FROM + abm_tokens +WHERE ID = ? + ` + + _, err := ds.writer(ctx).ExecContext(ctx, stmt, tokenID) + + return ctxerr.Wrap(ctx, err, "deleting ABM token") +} + +func (ds *Datastore) GetABMTokenByID(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) { + tok, err := ds.getABMToken(ctx, tokenID, "") + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get ABM token by id") + } + + return tok, nil +} + +func (ds *Datastore) getABMToken(ctx context.Context, tokenID uint, orgName string) (*fleet.ABMToken, error) { + stmt := ` +SELECT + abt.id, + abt.organization_name, + abt.apple_id, + abt.terms_expired, + abt.renew_at, + abt.token, + abt.macos_default_team_id, + abt.ios_default_team_id, + abt.ipados_default_team_id, + COALESCE(t1.name, :no_team) as macos_team, + COALESCE(t2.name, :no_team) as ios_team, + COALESCE(t3.name, :no_team) as ipados_team +FROM + abm_tokens abt +LEFT OUTER JOIN + teams t1 ON t1.id = abt.macos_default_team_id +LEFT OUTER JOIN + teams t2 ON t2.id = abt.ios_default_team_id +LEFT OUTER JOIN + teams t3 ON t3.id = abt.ipados_default_team_id +%s + ` + + stmt, args, err := sqlx.Named(stmt, map[string]any{"no_team": fleet.TeamNameNoTeam}) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build list ABM tokens query from named args") + } + + var ident any = orgName + clause := "WHERE abt.organization_name = ?" + if tokenID != 0 { + clause = "WHERE abt.id = ?" + ident = tokenID + } + + stmt = fmt.Sprintf(stmt, clause) + + args = append(args, ident) + + var tok fleet.ABMToken + if err := sqlx.GetContext(ctx, ds.reader(ctx), &tok, stmt, args...); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("ABMToken")) + } + + return nil, ctxerr.Wrap(ctx, err, "get ABM token") + } + + // decrypt the token with the serverPrivateKey, the resulting value will be + // the token still encrypted, but just with the ABM cert and key (it is that + // encrypted value that is stored with another layer of encryption with the + // serverPrivateKey). + decrypted, err := decrypt(tok.EncryptedToken, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "decrypting abm token with datastore.serverPrivateKey") + } + tok.EncryptedToken = decrypted + + cfg, err := ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get app config") + } + + url, err := apple_mdm.ResolveAppleMDMURL(cfg.ServerSettings.ServerURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting ABM token MDM server url") + } + + tok.MDMServerURL = url + + // Promote DB fields into respective objects + var macOSTeamID, iOSTeamID, iPadIOSTeamID uint + if tok.MacOSDefaultTeamID != nil { + macOSTeamID = *tok.MacOSDefaultTeamID + } + if tok.IOSDefaultTeamID != nil { + iOSTeamID = *tok.IOSDefaultTeamID + } + if tok.IPadOSDefaultTeamID != nil { + iPadIOSTeamID = *tok.IPadOSDefaultTeamID + } + + tok.MacOSTeam = fleet.ABMTokenTeam{Name: tok.MacOSTeamName, ID: macOSTeamID} + tok.IOSTeam = fleet.ABMTokenTeam{Name: tok.IOSTeamName, ID: iOSTeamID} + tok.IPadOSTeam = fleet.ABMTokenTeam{Name: tok.IPadOSTeamName, ID: iPadIOSTeamID} + + return &tok, nil +} + +func (ds *Datastore) GetABMTokenCount(ctx context.Context) (int, error) { + var count int + const countStmt = `SELECT COUNT(*) FROM abm_tokens` + + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, countStmt); err != nil { + return 0, ctxerr.Wrap(ctx, err, "counting existing ABM tokens") + } + + return count, nil +} + +func (ds *Datastore) SetABMTokenTermsExpiredForOrgName(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) { + const stmt = `UPDATE abm_tokens SET terms_expired = ? WHERE organization_name = ? AND terms_expired != ?` + res, err := ds.writer(ctx).ExecContext(ctx, stmt, expired, orgName, expired) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "update abm_tokens terms_expired") + } + affRows, _ := res.RowsAffected() + + if affRows > 0 { + // if it did update the row, then the previous value was the opposite of + // expired + wasSet = !expired + } else { + // if it did not update any row, then the previous value was the same + wasSet = expired + } + return wasSet, nil +} + +func (ds *Datastore) CountABMTokensWithTermsExpired(ctx context.Context) (int, error) { + // The expectation is that abm_tokens will have few rows (we don't even + // support pagination on the "list ABM tokens" endpoint), so this query + // should be very fast even without index on terms_expired. + const stmt = `SELECT COUNT(*) FROM abm_tokens WHERE terms_expired = 1` + + var count int + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, stmt); err != nil { + return 0, ctxerr.Wrap(ctx, err, "count ABM tokens with terms expired") + } + return count, nil +} + +func (ds *Datastore) GetABMTokenOrgNamesAssociatedWithTeam(ctx context.Context, teamID *uint) ([]string, error) { + stmt := ` +SELECT DISTINCT + abmt.organization_name +FROM + abm_tokens abmt + JOIN host_dep_assignments hda ON hda.abm_token_id = abmt.id + JOIN hosts h ON hda.host_id = h.id +WHERE + %s +UNION +SELECT DISTINCT + abmt.organization_name +FROM + abm_tokens abmt +WHERE + %s +` + var args []any + teamFilter := `h.team_id IS NULL` + abmtFilter := `abmt.macos_default_team_id IS NULL OR abmt.ios_default_team_id IS NULL OR abmt.ipados_default_team_id IS NULL` + if teamID != nil { + teamFilter = `h.team_id = ?` + abmtFilter = `abmt.macos_default_team_id = ? OR abmt.ios_default_team_id = ? OR abmt.ipados_default_team_id = ?` + args = append(args, *teamID, *teamID, *teamID, *teamID) + } + + stmt = fmt.Sprintf(stmt, teamFilter, abmtFilter) + + var orgNames []string + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &orgNames, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting org names for team from db") + } + + return orgNames, nil +} + +func (ds *Datastore) AddHostMDMCommands(ctx context.Context, commands []fleet.HostMDMCommand) error { + const baseStmt = ` + INSERT INTO host_mdm_commands (host_id, command_type) + VALUES %s + ON DUPLICATE KEY UPDATE + command_type = VALUES(command_type)` + + for i := 0; i < len(commands); i += addHostMDMCommandsBatchSize { + start := i + end := i + hostIssuesInsertBatchSize + if end > len(commands) { + end = len(commands) + } + totalToProcess := end - start + const numberOfArgsPerInsert = 2 // number of ? in each VALUES clause + values := strings.TrimSuffix( + strings.Repeat("(?,?),", totalToProcess), ",", + ) + stmt := fmt.Sprintf(baseStmt, values) + args := make([]interface{}, 0, totalToProcess*numberOfArgsPerInsert) + for j := start; j < end; j++ { + item := commands[j] + args = append( + args, item.HostID, item.CommandType, + ) + } + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert into host_mdm_commands") + } + } + + return nil +} + +func (ds *Datastore) GetHostMDMCommands(ctx context.Context, hostID uint) (commands []fleet.HostMDMCommand, err error) { + const stmt = `SELECT host_id, command_type FROM host_mdm_commands WHERE host_id = ?` + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &commands, stmt, hostID); err != nil { + return nil, err + } + return commands, nil +} + +func (ds *Datastore) RemoveHostMDMCommand(ctx context.Context, command fleet.HostMDMCommand) error { + const stmt = ` + DELETE FROM host_mdm_commands + WHERE host_id = ? AND command_type = ?` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, command.HostID, command.CommandType); err != nil { + return ctxerr.Wrap(ctx, err, "delete from host_mdm_commands") + } + return nil +} + +func (ds *Datastore) CleanupHostMDMCommands(ctx context.Context) error { + // Delete commands that don't have a corresponding host or have been sent over 1 day ago. + // We are using 1 day instead of 7 days in case MDM commands fail to be sent or fail to process. They can be resent the next day. + const stmt = ` + DELETE hmc FROM host_mdm_commands AS hmc + LEFT JOIN hosts h ON h.id = hmc.host_id + WHERE h.id IS NULL OR hmc.updated_at < NOW() - INTERVAL 1 DAY` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt); err != nil { + return ctxerr.Wrap(ctx, err, "delete from host_mdm_commands") + } + return nil +} + +func (ds *Datastore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + stmt := ` +SELECT + team_id, platform +FROM + hosts h +JOIN + host_dep_assignments hdep ON h.id = host_id +WHERE + hardware_serial = ? AND deleted_at IS NULL +LIMIT 1` + + var dest struct { + TeamID *uint `db:"team_id"` + Platform string `db:"platform"` + } + if err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, serial); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting team id for host") + } + + var settings fleet.AppleOSUpdateSettings + if dest.TeamID == nil { + // use the global settings + ac, err := ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config for os update settings") + } + switch dest.Platform { + case "ios": + settings = ac.MDM.IOSUpdates + case "ipados": + settings = ac.MDM.IPadOSUpdates + case "darwin": + settings = ac.MDM.MacOSUpdates + default: + return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) + } + } else { + // use the team settings + tm, err := ds.TeamWithoutExtras(ctx, *dest.TeamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting team os update settings") + } + switch dest.Platform { + case "ios": + settings = tm.Config.MDM.IOSUpdates + case "ipados": + settings = tm.Config.MDM.IPadOSUpdates + case "darwin": + settings = tm.Config.MDM.MacOSUpdates + default: + return nil, ctxerr.New(ctx, fmt.Sprintf("unsupported platform %s", dest.Platform)) + } + } + + return &settings, nil +} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 35a8a87497..3090bdb04d 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -6,6 +6,7 @@ import ( "crypto/md5" // nolint:gosec // used only to hash for efficient comparisons "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -16,6 +17,7 @@ import ( "github.com/VividCortex/mysqlerr" "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/datastore/s3" "github.com/fleetdm/fleet/v4/server/fleet" fleetmdm "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -81,9 +83,16 @@ func TestMDMApple(t *testing.T) { {"IngestMDMAppleDevicesFromDEPSyncIOSIPadOS", testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS}, {"MDMAppleProfilesOnIOSIPadOS", testMDMAppleProfilesOnIOSIPadOS}, {"GetHostUUIDsWithPendingMDMAppleCommands", testGetHostUUIDsWithPendingMDMAppleCommands}, + {"MDMAppleBootstrapPackageWithS3", testMDMAppleBootstrapPackageWithS3}, + {"GetAndUpdateABMToken", testMDMAppleGetAndUpdateABMToken}, + {"ABMTokensTermsExpired", testMDMAppleABMTokensTermsExpired}, + {"TestMDMGetABMTokenOrgNamesAssociatedWithTeam", testMDMGetABMTokenOrgNamesAssociatedWithTeam}, + {"HostMDMCommands", testHostMDMCommands}, + {"IngestMDMAppleDeviceFromOTAEnrollment", testIngestMDMAppleDeviceFromOTAEnrollment}, } for _, c := range cases { + t.Helper() t.Run(c.name, func(t *testing.T) { defer TruncateTables(t, ds) @@ -617,10 +626,14 @@ func TestIngestMDMAppleDevicesFromDEPSync(t *testing.T) { } wantSerials = append(wantSerials, "abc", "xyz", "ijk", "tuv") - n, tmID, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.EqualValues(t, 4, n) // 4 new hosts ("abc", "xyz", "ijk", "tuv") - require.Nil(t, tmID) hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, len(wantSerials)) gotSerials := []string{} @@ -643,9 +656,13 @@ func TestDEPSyncTeamAssignment(t *testing.T) { {SerialNumber: "def", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, } - n, tmID, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, nil, nil, nil) require.NoError(t, err) - require.Nil(t, tmID) require.Equal(t, int64(2), n) hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 2) @@ -660,7 +677,7 @@ func TestDEPSyncTeamAssignment(t *testing.T) { // assign the team as the default team for DEP devices ac, err := ds.AppConfig(context.Background()) require.NoError(t, err) - ac.MDM.AppleBMDefaultTeam = team.Name + ac.MDM.DeprecatedAppleBMDefaultTeam = team.Name err = ds.SaveAppConfig(context.Background(), ac) require.NoError(t, err) @@ -669,11 +686,9 @@ func TestDEPSyncTeamAssignment(t *testing.T) { {SerialNumber: "xyz", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, } - n, tmID, err = ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + n, err = ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, team, team, team) require.NoError(t, err) require.Equal(t, int64(1), n) - require.NotNil(t, tmID) - require.Equal(t, team.ID, *tmID) hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 3) for _, h := range hosts { @@ -684,18 +699,14 @@ func TestDEPSyncTeamAssignment(t *testing.T) { } } - ac.MDM.AppleBMDefaultTeam = "non-existent" - err = ds.SaveAppConfig(context.Background(), ac) - require.NoError(t, err) - + nonExistentTeam := &fleet.Team{ID: 8888} depDevices = []godep.Device{ {SerialNumber: "jqk", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, } - n, tmID, err = ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + n, err = ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, nonExistentTeam, nonExistentTeam, nonExistentTeam) require.NoError(t, err) require.EqualValues(t, n, 1) - require.Nil(t, tmID) hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 4) for _, h := range hosts { @@ -817,13 +828,17 @@ func testIngestMDMAppleIngestAfterDEPSync(t *testing.T, ds *Datastore) { testUUID := "test-uuid" testModel := "MacBook Pro" + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + // simulate a host that is first ingested via DEP (e.g., the device was added via Apple Business Manager) - n, tmID, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ {SerialNumber: testSerial, Model: testModel, OS: "OSX", OpType: "added"}, - }) + }, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(1), n) - require.Nil(t, tmID) hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1) // hosts that are first ingested via DEP will have a serial number but not a UUID because UUID @@ -864,13 +879,17 @@ func testIngestMDMAppleCheckinBeforeDEPSync(t *testing.T, ds *Datastore) { require.Equal(t, testUUID, hosts[0].UUID) checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, testModel) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + // no effect if same host appears in DEP sync - n, tmID, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ {SerialNumber: testSerial, Model: testModel, OS: "OSX", OpType: "added"}, - }) + }, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(0), n) - require.Nil(t, tmID) hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1) require.Equal(t, testSerial, hosts[0].HardwareSerial) @@ -1040,7 +1059,9 @@ func expectAppleDeclarations( var got []*fleet.MDMAppleDeclaration ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_apple_declarations WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT declaration_uuid, team_id, identifier, name, raw_json, checksum, created_at, uploaded_at FROM mdm_apple_declarations WHERE team_id = ?`, + tmID) }) // create map of expected declarations keyed by identifier @@ -1325,16 +1346,20 @@ func teamConfigProfileForTest(t *testing.T, name, identifier, uuid string, teamI } func testMDMAppleProfileManagementBatch2(t *testing.T, ds *Datastore) { + ds.testSelectMDMProfilesBatchSize = 2 ds.testUpsertMDMDesiredProfilesBatchSize = 2 t.Cleanup(func() { + ds.testSelectMDMProfilesBatchSize = 0 ds.testUpsertMDMDesiredProfilesBatchSize = 0 }) testMDMAppleProfileManagement(t, ds) } func testMDMAppleProfileManagementBatch3(t *testing.T, ds *Datastore) { + ds.testSelectMDMProfilesBatchSize = 3 ds.testUpsertMDMDesiredProfilesBatchSize = 3 t.Cleanup(func() { + ds.testSelectMDMProfilesBatchSize = 0 ds.testUpsertMDMDesiredProfilesBatchSize = 0 }) testMDMAppleProfileManagement(t, ds) @@ -3140,7 +3165,7 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) { var nfe fleet.NotFoundError var aerr fleet.AlreadyExistsError - err := ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{}) + err := ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{}, nil) require.Error(t, err) bp1 := &fleet.MDMAppleBootstrapPackage{ @@ -3150,10 +3175,10 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) { Bytes: []byte("content"), Token: uuid.New().String(), } - err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1) + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1, nil) require.NoError(t, err) - err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1) + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1, nil) require.ErrorAs(t, err, &aerr) bp2 := &fleet.MDMAppleBootstrapPackage{ @@ -3163,7 +3188,7 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) { Bytes: []byte("content"), Token: uuid.New().String(), } - err = ds.InsertMDMAppleBootstrapPackage(ctx, bp2) + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp2, nil) require.NoError(t, err) meta, err := ds.GetMDMAppleBootstrapPackageMeta(ctx, 0) @@ -3177,11 +3202,11 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) { require.ErrorAs(t, err, &nfe) require.Nil(t, meta) - bytes, err := ds.GetMDMAppleBootstrapPackageBytes(ctx, bp1.Token) + bytes, err := ds.GetMDMAppleBootstrapPackageBytes(ctx, bp1.Token, nil) require.NoError(t, err) require.Equal(t, bp1.Bytes, bytes.Bytes) - bytes, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, "fake") + bytes, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, "fake", nil) require.ErrorAs(t, err, &nfe) require.Nil(t, bytes) @@ -3453,6 +3478,10 @@ func testMDMAppleSetupAssistant(t *testing.T, ds *Datastore) { require.Error(t, err) require.ErrorIs(t, err, sql.ErrNoRows) require.True(t, fleet.IsNotFound(err)) + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, nil, "no-such-token") + require.Error(t, err) + require.ErrorIs(t, err, sql.ErrNoRows) + require.True(t, fleet.IsNotFound(err)) // create for no team noTeamAsst, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{Name: "test", Profile: json.RawMessage("{}")}) @@ -3492,7 +3521,59 @@ func testMDMAppleSetupAssistant(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, tmAsst, getAsst) - // upsert team + // create an ABM token and set a profile uuid for no team + tok1, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "o1", EncryptedToken: []byte(uuid.NewString())}) + require.NoError(t, err) + require.NotZero(t, tok1.ID) + profUUID1 := uuid.NewString() + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, nil, profUUID1, "o1") + require.NoError(t, err) + gotProf, gotTs, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, nil, "o1") + require.NoError(t, err) + require.Equal(t, profUUID1, gotProf) + require.NotZero(t, gotTs) + + // set a profile uuid for an unknown token, no error but nothing inserted + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, nil, profUUID1, "no-such-token") + require.NoError(t, err) + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, nil, "no-such-token") + require.Error(t, err) + require.ErrorIs(t, err, sql.ErrNoRows) + require.True(t, fleet.IsNotFound(err)) + + // create another ABM token and set a profile uuid for the team assistant + // with both tokens + tok2, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "o2", EncryptedToken: []byte(uuid.NewString())}) + require.NoError(t, err) + require.NotZero(t, tok2.ID) + profUUID2 := uuid.NewString() + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, profUUID1, "o1") + require.NoError(t, err) + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, profUUID2, "o2") + require.NoError(t, err) + gotProf, gotTs, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o1") + require.NoError(t, err) + require.Equal(t, profUUID1, gotProf) + require.NotZero(t, gotTs) + gotProf, gotTs, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o2") + require.NoError(t, err) + require.Equal(t, profUUID2, gotProf) + require.NotZero(t, gotTs) + + // update the profile uuid for o2 only + profUUID3 := uuid.NewString() + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, profUUID3, "o2") + require.NoError(t, err) + gotProf, gotTs, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o1") + require.NoError(t, err) + require.Equal(t, profUUID1, gotProf) + require.NotZero(t, gotTs) + gotProf, gotTs, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o2") + require.NoError(t, err) + require.Equal(t, profUUID3, gotProf) + require.NotZero(t, gotTs) + + // upsert team assistant tmAsst2, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":2}`)}) require.NoError(t, err) require.Equal(t, tmAsst2.ID, tmAsst.ID) @@ -3501,7 +3582,13 @@ func testMDMAppleSetupAssistant(t *testing.T, ds *Datastore) { require.Equal(t, "test2", tmAsst2.Name) require.JSONEq(t, `{"x": 2}`, string(tmAsst2.Profile)) - // upsert no team + // profile uuids have been cleared + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o1") + require.ErrorIs(t, err, sql.ErrNoRows) + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o2") + require.ErrorIs(t, err, sql.ErrNoRows) + + // upsert no team assistant noTeamAsst2, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{Name: "test3", Profile: json.RawMessage(`{"x": 3}`)}) require.NoError(t, err) require.Equal(t, noTeamAsst2.ID, noTeamAsst.ID) @@ -3510,74 +3597,52 @@ func testMDMAppleSetupAssistant(t *testing.T, ds *Datastore) { require.Equal(t, "test3", noTeamAsst2.Name) require.JSONEq(t, `{"x": 3}`, string(noTeamAsst2.Profile)) + // profile uuid has been cleared + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, nil, "o1") + require.ErrorIs(t, err, sql.ErrNoRows) + time.Sleep(time.Second) // ensures the timestamp checks are not by chance + // set profile uuids for team and no team (one each) + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, nil, profUUID1, "o1") + require.NoError(t, err) + err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, profUUID2, "o2") + require.NoError(t, err) + // upsert team no change, uploaded at timestamp does not change tmAsst3, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":2}`)}) require.NoError(t, err) require.Equal(t, tmAsst2, tmAsst3) - // set a profile uuid for the team assistant - err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, "abcd") - require.NoError(t, err) - - // get for team returns the same data, but now with a profile uuid - getAsst, err = ds.GetMDMAppleSetupAssistant(ctx, &tm.ID) - require.NoError(t, err) - require.Equal(t, "abcd", getAsst.ProfileUUID) - getAsst.ProfileUUID = "" - require.Equal(t, tmAsst3, getAsst) - - time.Sleep(time.Second) // ensures the timestamp checks are not by chance - - // upsert again the team with no change, uploaded at timestamp does not change nor does the profile uuid - tmAsst4, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":2}`)}) - require.NoError(t, err) - require.Equal(t, "abcd", tmAsst4.ProfileUUID) - tmAsst4.ProfileUUID = "" - require.Equal(t, tmAsst3, tmAsst4) + // TODO(mna): ideally the profiles would not be cleared when the profile + // stayed the same, but does not work at the moment and we're pressed by + // time. + // gotProf, gotTs, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o2") + // require.NoError(t, err) + // require.Equal(t, profUUID2, gotProf) + // require.Equal(t, tmAsst3.UploadedAt, gotTs) time.Sleep(time.Second) // ensures the timestamp checks are not by chance // upsert team with a change, clears the profile uuid and updates the uploaded at timestamp - tmAsst5, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":3}`)}) + tmAsst4, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":3}`)}) require.NoError(t, err) - require.Equal(t, tmAsst4.ID, tmAsst5.ID) - require.True(t, tmAsst5.UploadedAt.After(tmAsst4.UploadedAt)) - require.Equal(t, tmAsst4.TeamID, tmAsst5.TeamID) - require.Equal(t, "test2", tmAsst5.Name) - require.Empty(t, tmAsst5.ProfileUUID) - require.JSONEq(t, `{"x": 3}`, string(tmAsst5.Profile)) + require.Equal(t, tmAsst3.ID, tmAsst4.ID) + require.True(t, tmAsst4.UploadedAt.After(tmAsst3.UploadedAt)) + require.Equal(t, tmAsst3.TeamID, tmAsst4.TeamID) + require.Equal(t, "test2", tmAsst4.Name) + require.JSONEq(t, `{"x": 3}`, string(tmAsst4.Profile)) - // set a profile uuid for the team assistant - err = ds.SetMDMAppleSetupAssistantProfileUUID(ctx, &tm.ID, "efgh") - require.NoError(t, err) - - time.Sleep(time.Second) // ensures the timestamp checks are not by chance - - // upsert again the team with no change - tmAsst6, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test2", Profile: json.RawMessage(`{"x":3}`)}) - require.NoError(t, err) - require.Equal(t, "efgh", tmAsst6.ProfileUUID) - tmAsst6.ProfileUUID = "" - require.Equal(t, tmAsst5, tmAsst6) - - time.Sleep(time.Second) // ensures the timestamp checks are not by chance - - // upsert team with a name change - tmAsst7, err := ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{TeamID: &tm.ID, Name: "test3", Profile: json.RawMessage(`{"x":3}`)}) - require.NoError(t, err) - require.Equal(t, tmAsst6.ID, tmAsst7.ID) - require.True(t, tmAsst7.UploadedAt.After(tmAsst6.UploadedAt)) - require.Equal(t, tmAsst6.TeamID, tmAsst7.TeamID) - require.Equal(t, "test3", tmAsst7.Name) - require.Empty(t, tmAsst7.ProfileUUID) - require.JSONEq(t, `{"x": 3}`, string(tmAsst7.Profile)) + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, "o2") + require.ErrorIs(t, err, sql.ErrNoRows) // delete no team err = ds.DeleteMDMAppleSetupAssistant(ctx, nil) require.NoError(t, err) + _, _, err = ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, nil, "o1") + require.ErrorIs(t, err, sql.ErrNoRows) + // delete the team, which will cascade delete the setup assistant err = ds.DeleteTeam(ctx, tm.ID) require.NoError(t, err) @@ -3648,6 +3713,11 @@ func testMDMAppleEnrollmentProfile(t *testing.T, ds *Datastore) { func testListMDMAppleSerials(t *testing.T, ds *Datastore) { ctx := context.Background() + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + // create a mix of DEP-enrolled hosts, non-Fleet-MDM, pending DEP-enrollment hosts := make([]*fleet.Host, 7) for i := 0; i < len(hosts); i++ { @@ -3667,19 +3737,19 @@ func testListMDMAppleSerials(t *testing.T, ds *Datastore) { switch { case i <= 3: // assigned in ABM to Fleet - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmToken.ID) require.NoError(t, err) case i == 4: // not ABM assigned case i == 5: // ABM assignment was deleted - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmToken.ID) require.NoError(t, err) err = ds.DeleteHostDEPAssignments(ctx, []string{h.HardwareSerial}) require.NoError(t, err) case i == 6: // assigned in ABM, but we don't have a serial - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmToken.ID) require.NoError(t, err) } hosts[i] = h @@ -3738,28 +3808,36 @@ func testListMDMAppleSerials(t *testing.T, ds *Datastore) { func testMDMAppleDefaultSetupAssistant(t *testing.T, ds *Datastore) { ctx := context.Background() + // create a couple ABM tokens + tok1, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "o1", EncryptedToken: []byte(uuid.NewString())}) + require.NoError(t, err) + require.NotEmpty(t, tok1.ID) + tok2, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "o2", EncryptedToken: []byte(uuid.NewString())}) + require.NoError(t, err) + require.NotEmpty(t, tok2.ID) + // get non-existing - _, _, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "no-such-token") require.ErrorIs(t, err, sql.ErrNoRows) require.True(t, fleet.IsNotFound(err)) // set for no team - err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, nil, "no-team") + err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, nil, "no-team", "o1") require.NoError(t, err) // get for no team returns the same data - uuid, ts, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil) + uuid, ts, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "o1") require.NoError(t, err) require.Equal(t, "no-team", uuid) require.NotZero(t, ts) // set for non-existing team fails - err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, ptr.Uint(123), "xyz") + err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, ptr.Uint(123), "xyz", "o2") require.Error(t, err) require.ErrorContains(t, err, "foreign key constraint fails") // get for non-existing team fails - _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, ptr.Uint(123)) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, ptr.Uint(123), "o2") require.ErrorIs(t, err, sql.ErrNoRows) require.True(t, fleet.IsNotFound(err)) @@ -3767,15 +3845,35 @@ func testMDMAppleDefaultSetupAssistant(t *testing.T, ds *Datastore) { tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "tm"}) require.NoError(t, err) - // set for existing team - err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, &tm.ID, "tm") + // set a couple profiles for existing team + err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, &tm.ID, "tm1", "o1") + require.NoError(t, err) + err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, &tm.ID, "tm2", "o2") require.NoError(t, err) // get for existing team - uuid, ts, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID) + uuid, ts, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "o1") require.NoError(t, err) - require.Equal(t, "tm", uuid) + require.Equal(t, "tm1", uuid) require.NotZero(t, ts) + uuid, ts, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "o2") + require.NoError(t, err) + require.Equal(t, "tm2", uuid) + require.NotZero(t, ts) + // get for unknown abm token + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "no-such-token") + require.ErrorIs(t, err, sql.ErrNoRows) + require.True(t, fleet.IsNotFound(err)) + + // clear all profiles for team + err = ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, &tm.ID, "", "") + require.NoError(t, err) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "o1") + require.ErrorIs(t, err, sql.ErrNoRows) + require.True(t, fleet.IsNotFound(err)) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "o2") + require.ErrorIs(t, err, sql.ErrNoRows) + require.True(t, fleet.IsNotFound(err)) } func testSetVerifiedMacOSProfiles(t *testing.T, ds *Datastore) { @@ -4061,7 +4159,7 @@ func TestCopyDefaultMDMAppleBootstrapPackage(t *testing.T) { Bytes: []byte("content"), Token: uuid.New().String(), } - err = ds.InsertMDMAppleBootstrapPackage(ctx, defaultBP) + err = ds.InsertMDMAppleBootstrapPackage(ctx, defaultBP, nil) require.NoError(t, err) checkStoredBP(noTeamID, nil, false, defaultBP) // default bootstrap package is stored checkStoredBP(teamID, sql.ErrNoRows, false, nil) // no bootstrap package yet for team @@ -4091,7 +4189,7 @@ func TestCopyDefaultMDMAppleBootstrapPackage(t *testing.T) { Bytes: []byte("new content"), Token: uuid.New().String(), } - err = ds.InsertMDMAppleBootstrapPackage(ctx, defaultBP2) + err = ds.InsertMDMAppleBootstrapPackage(ctx, defaultBP2, nil) require.NoError(t, err) checkStoredBP(noTeamID, nil, false, defaultBP2) // set bootstrap package url in app config @@ -4185,13 +4283,18 @@ func TestHostDEPAssignments(t *testing.T) { expectedMDMServerURL, err := apple_mdm.ResolveAppleEnrollMDMURL(ac.ServerSettings.ServerURL) require.NoError(t, err) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + t.Run("DEP enrollment", func(t *testing.T) { depSerial := "dep-serial" depUUID := "dep-uuid" depOrbitNodeKey := "dep-orbit-node-key" depDeviceTok := "dep-device-token" - n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{{SerialNumber: depSerial}}) + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{{SerialNumber: depSerial}}, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(1), n) @@ -4214,6 +4317,8 @@ func TestHostDEPAssignments(t *testing.T) { require.Equal(t, depHostID, depAssignment.HostID) require.Nil(t, depAssignment.DeletedAt) require.WithinDuration(t, time.Now(), depAssignment.AddedAt, 5*time.Second) + require.NotNil(t, depAssignment.ABMTokenID) + require.Equal(t, *depAssignment.ABMTokenID, abmToken.ID) // simulate initial osquery enrollment via Orbit testHost, err := ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{HardwareSerial: depSerial, Platform: "darwin", HardwareUUID: depUUID, Hostname: "dep-host"}, depOrbitNodeKey, nil) @@ -4511,7 +4616,7 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) { Sha256: sha256.New().Sum(nil), Bytes: []byte("content"), Token: uuid.New().String(), - }) + }, nil) require.NoError(t, err) err = ds.RecordHostBootstrapPackage(ctx, "command-uuid", host.UUID) require.NoError(t, err) @@ -4549,6 +4654,11 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) { func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) { ctx := context.Background() + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + cases := []struct { name string in []string @@ -4568,7 +4678,7 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) { {SerialNumber: "bar"}, {SerialNumber: "baz"}, } - _, _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, devices) + _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, devices, abmToken.ID, nil, nil, nil) require.NoError(t, err) err = ds.DeleteHostDEPAssignments(ctx, tt.in) @@ -4755,8 +4865,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-1", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, "not-exists") require.NoError(t, err) @@ -4773,8 +4886,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { }) require.NoError(t, err) nanoEnroll(t, ds, host1, true) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4787,8 +4903,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-2", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4799,8 +4918,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { err = ds.DeleteMDMAppleConfigProfile(ctx, decl.DeclarationUUID) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -5368,6 +5490,11 @@ func TestRestorePendingDEPHost(t *testing.T) { expectedMDMServerURL, err := apple_mdm.ResolveAppleEnrollMDMURL(ac.ServerSettings.ServerURL) require.NoError(t, err) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + t.Run("DEP enrollment", func(t *testing.T) { checkHostExistsInTable := func(t *testing.T, tableName string, hostID uint, expected bool, where ...string) { stmt := "SELECT 1 FROM " + tableName + " WHERE host_id = ?" @@ -5419,7 +5546,7 @@ func TestRestorePendingDEPHost(t *testing.T) { depUUID := "dep-uuid" depOrbitNodeKey := "dep-orbit-node-key" - n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{{SerialNumber: depSerial}}) + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{{SerialNumber: depSerial}}, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(1), n) @@ -5501,10 +5628,15 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + _, err = ds.GetHostDEPAssignment(ctx, h.ID) require.ErrorIs(t, err, sql.ErrNoRows) - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmToken.ID) require.NoError(t, err) assignment, err := ds.GetHostDEPAssignment(ctx, h.ID) @@ -5520,7 +5652,7 @@ func testMDMAppleDEPAssignmentUpdates(t *testing.T, ds *Datastore) { require.Equal(t, h.ID, assignment.HostID) require.NotNil(t, assignment.DeletedAt) - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmToken.ID) require.NoError(t, err) assignment, err = ds.GetHostDEPAssignment(ctx, h.ID) require.NoError(t, err) @@ -5691,10 +5823,15 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { return h } - // Test with no hosts. - uuids, err := ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) require.NoError(t, err) - require.Empty(t, uuids) + require.NotEmpty(t, abmToken.ID) + + // Test with no hosts. + devices, err := ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + require.NoError(t, err) + require.Empty(t, devices) // Create a placeholder macOS host. _ = newHost("darwin") @@ -5704,14 +5841,14 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { {SerialNumber: "iOS0_SERIAL", DeviceFamily: "iPhone", OpType: "added"}, {SerialNumber: "iPadOS0_SERIAL", DeviceFamily: "iPad", OpType: "added"}, } - n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(2), n) // Hosts are not enrolled yet (e.g. DEP enrolled) - uuids, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) require.NoError(t, err) - require.Empty(t, uuids) + require.Empty(t, devices) // Now simulate the initial MDM checkin of the devices. err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{ @@ -5738,13 +5875,16 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { nanoEnroll(t, ds, iPadOS0, false) // Test with hosts but empty state in nanomdm command tables. - uuids, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) require.NoError(t, err) - require.Len(t, uuids, 2) + require.Len(t, devices, 2) + uuids := []string{devices[0].UUID, devices[1].UUID} sort.Slice(uuids, func(i, j int) bool { return uuids[i] < uuids[j] }) - require.Equal(t, uuids, []string{"iOS0_UUID", "iPadOS0_UUID"}) + assert.Equal(t, uuids, []string{"iOS0_UUID", "iPadOS0_UUID"}) + assert.Empty(t, devices[0].CommandsAlreadySent) + assert.Empty(t, devices[1].CommandsAlreadySent) // Set iOS detail_updated_at as 30 minutes in the past. ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5753,10 +5893,10 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { }) // iOS device should not be returned because it was refetched recently - uuids, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) require.NoError(t, err) - require.Len(t, uuids, 1) - require.Equal(t, uuids[0], "iPadOS0_UUID") + require.Len(t, devices, 1) + require.Equal(t, devices[0].UUID, "iPadOS0_UUID") // Set iPadOS detail_updated_at as 30 minutes in the past. ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5765,9 +5905,9 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { }) // Both devices are up-to-date thus none should be returned. - uuids, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) require.NoError(t, err) - require.Empty(t, uuids) + require.Empty(t, devices) // Set iOS detail_updated_at as 2 hours in the past. ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5776,10 +5916,23 @@ func testListIOSAndIPadOSToRefetch(t *testing.T, ds *Datastore) { }) // iOS device be returned because it is out of date. - uuids, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) require.NoError(t, err) - require.Len(t, uuids, 1) - require.Equal(t, uuids[0], "iOS0_UUID") + require.Len(t, devices, 1) + require.Equal(t, devices[0].UUID, "iOS0_UUID") + assert.Empty(t, devices[0].CommandsAlreadySent) + + // Update commands already sent to the devices and check that they are returned. + require.NoError(t, ds.AddHostMDMCommands(ctx, []fleet.HostMDMCommand{{ + HostID: iOS0.ID, + CommandType: "my-command", + }})) + devices, err = ds.ListIOSAndIPadOSToRefetch(ctx, refetchInterval) + require.NoError(t, err) + require.Len(t, devices, 1) + require.Equal(t, devices[0].UUID, "iOS0_UUID") + require.Len(t, devices[0].CommandsAlreadySent, 1) + assert.Equal(t, "my-command", devices[0].CommandsAlreadySent[0]) } func testMDMAppleUpsertHostIOSIPadOS(t *testing.T, ds *Datastore) { @@ -5869,7 +6022,12 @@ func testIngestMDMAppleDevicesFromDEPSyncIOSIPadOS(t *testing.T, ds *Datastore) {SerialNumber: "iPadOS0_SERIAL", DeviceFamily: "iPad", OpType: "added"}, } - n, _, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(2), n) @@ -5942,8 +6100,11 @@ func testMDMAppleProfilesOnIOSIPadOS(t *testing.T, ds *Datastore) { someProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("a", "a", 0)) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) profiles, err := ds.GetHostMDMAppleProfiles(ctx, "iOS0_UUID") require.NoError(t, err) @@ -6001,6 +6162,7 @@ func testGetHostUUIDsWithPendingMDMAppleCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) require.ElementsMatch(t, []string{hosts[1].UUID, hosts[2].UUID}, uuids) } + func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6143,3 +6305,797 @@ func testHostDetailsMDMProfilesIOSIPadOS(t *testing.T, ds *Datastore) { require.Equal(t, fleet.MDMDeliveryVerified, *gotProfs[0].Status) } } + +func testMDMAppleBootstrapPackageWithS3(t *testing.T, ds *Datastore) { + ctx := context.Background() + var nfe fleet.NotFoundError + var aerr fleet.AlreadyExistsError + + hashContent := func(content string) []byte { + h := sha256.New() + _, err := h.Write([]byte(content)) + require.NoError(t, err) + return h.Sum(nil) + } + + bpMatchesWithoutContent := func(want, got *fleet.MDMAppleBootstrapPackage) { + // make local copies so we don't alter the caller's structs + w, g := *want, *got + w.Bytes, g.Bytes = nil, nil + w.CreatedAt, g.CreatedAt = time.Time{}, time.Time{} + w.UpdatedAt, g.UpdatedAt = time.Time{}, time.Time{} + require.Equal(t, w, g) + } + + pkgStore := s3.SetupTestBootstrapPackageStore(t, "mdm-apple-bootstrap-package-test", "") + + err := ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{}, pkgStore) + require.Error(t, err) + + // associate bp1 with no team + bp1 := &fleet.MDMAppleBootstrapPackage{ + TeamID: uint(0), + Name: "bp1", + Sha256: hashContent("bp1"), + Bytes: []byte("bp1"), + Token: uuid.New().String(), + } + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1, pkgStore) + require.NoError(t, err) + + // try to store bp1 again, fails as it already exists + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp1, pkgStore) + require.ErrorAs(t, err, &aerr) + + // associate bp2 with team id 2 + bp2 := &fleet.MDMAppleBootstrapPackage{ + TeamID: uint(2), + Name: "bp2", + Sha256: hashContent("bp2"), + Bytes: []byte("bp2"), + Token: uuid.New().String(), + } + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp2, pkgStore) + require.NoError(t, err) + + // associate the same content as bp1 with team id 1, via a copy + err = ds.CopyDefaultMDMAppleBootstrapPackage(ctx, &fleet.AppConfig{}, 1) + require.NoError(t, err) + + // get bp for no team + meta, err := ds.GetMDMAppleBootstrapPackageMeta(ctx, 0) + require.NoError(t, err) + bpMatchesWithoutContent(bp1, meta) + + // get for team 1, token differs due to the copy, rest is the same + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 1) + require.NoError(t, err) + require.NotEqual(t, bp1.Token, meta.Token) + bp1b := *bp1 + bp1b.Token = meta.Token + bp1b.TeamID = 1 + bpMatchesWithoutContent(&bp1b, meta) + + // get for team 2 + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 2) + require.NoError(t, err) + bpMatchesWithoutContent(bp2, meta) + + // get for team 3, does not exist + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 3) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + + // get content for no team + bpContent, err := ds.GetMDMAppleBootstrapPackageBytes(ctx, bp1.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp1.Bytes, bpContent.Bytes) + + // get content for team 1 (copy of no team) + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp1b.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp1b.Bytes, bpContent.Bytes) + require.Equal(t, bp1.Bytes, bpContent.Bytes) + + // get content for team 2 + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp2.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp2.Bytes, bpContent.Bytes) + + // get content with invalid token + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, "no-such-token", pkgStore) + require.ErrorAs(t, err, &nfe) + require.Nil(t, bpContent) + + // delete bp for no team and team 2 + err = ds.DeleteMDMAppleBootstrapPackage(ctx, 0) + require.NoError(t, err) + err = ds.DeleteMDMAppleBootstrapPackage(ctx, 2) + require.NoError(t, err) + + // run the cleanup job + err = ds.CleanupUnusedBootstrapPackages(ctx, pkgStore, time.Now()) + require.NoError(t, err) + + // team 1 can still be retrieved (it shares the same contents) + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp1b.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp1b.Bytes, bpContent.Bytes) + + // team 0 and 2 don't exist anymore + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 0) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 2) + require.ErrorAs(t, err, &nfe) + require.Nil(t, meta) + + ok, err := pkgStore.Exists(ctx, hex.EncodeToString(bp1.Sha256)) + require.NoError(t, err) + require.True(t, ok) + ok, err = pkgStore.Exists(ctx, hex.EncodeToString(bp2.Sha256)) + require.NoError(t, err) + require.False(t, ok) + + // delete team 1 + err = ds.DeleteMDMAppleBootstrapPackage(ctx, 1) + require.NoError(t, err) + + // force a team 3 bp to be saved in the DB (simulates upgrading to the new + // S3-based storage with already-saved bps in the DB) + bp3 := &fleet.MDMAppleBootstrapPackage{ + TeamID: uint(3), + Name: "bp3", + Sha256: hashContent("bp3"), + Bytes: []byte("bp3"), + Token: uuid.New().String(), + } + err = ds.InsertMDMAppleBootstrapPackage(ctx, bp3, nil) // passing a nil pkgStore to force save in the DB + require.NoError(t, err) + + // metadata can be read + meta, err = ds.GetMDMAppleBootstrapPackageMeta(ctx, 3) + require.NoError(t, err) + bpMatchesWithoutContent(bp3, meta) + + // content will be retrieved correctly from the DB even if we pass a pkgStore + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp3.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp3.Bytes, bpContent.Bytes) + + // run the cleanup job + err = ds.CleanupUnusedBootstrapPackages(ctx, pkgStore, time.Now()) + require.NoError(t, err) + + ok, err = pkgStore.Exists(ctx, hex.EncodeToString(bp1.Sha256)) + require.NoError(t, err) + require.False(t, ok) + ok, err = pkgStore.Exists(ctx, hex.EncodeToString(bp2.Sha256)) + require.NoError(t, err) + require.False(t, ok) + // bp3 does not exist in the S3 store + ok, err = pkgStore.Exists(ctx, hex.EncodeToString(bp3.Sha256)) + require.NoError(t, err) + require.False(t, ok) + + // so it can still be retrieved from the DB + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp3.Token, pkgStore) + require.NoError(t, err) + require.Equal(t, bp3.Bytes, bpContent.Bytes) + + // it can be deleted without problem + err = ds.DeleteMDMAppleBootstrapPackage(ctx, 3) + require.NoError(t, err) + + bpContent, err = ds.GetMDMAppleBootstrapPackageBytes(ctx, bp3.Token, pkgStore) + require.ErrorAs(t, err, &nfe) + require.Nil(t, bpContent) +} + +func testMDMAppleGetAndUpdateABMToken(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // get a non-existing token + tok, err := ds.GetABMTokenByOrgName(ctx, "no-such-token") + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + require.Nil(t, tok) + + // create some teams + tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + tm3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + // create a token with an empty name and no team set, and another that will be unused + encTok := uuid.NewString() + + t1, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, t1.ID) + t2, err := ds.InsertABMToken(ctx, &fleet.ABMToken{EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, t2.ID) + + toks, err := ds.ListABMTokens(ctx) + require.NoError(t, err) + require.Len(t, toks, 2) + + // get that token + tok, err = ds.GetABMTokenByOrgName(ctx, "") + require.NoError(t, err) + require.NotZero(t, tok.ID) + require.Equal(t, encTok, string(tok.EncryptedToken)) + require.Empty(t, tok.OrganizationName) + require.Empty(t, tok.AppleID) + require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeamName) + + // update the token with a name and teams + tok.OrganizationName = "org-name" + tok.AppleID = "name@example.com" + tok.MacOSDefaultTeamID = &tm1.ID + tok.IOSDefaultTeamID = &tm2.ID + err = ds.SaveABMToken(ctx, tok) + require.NoError(t, err) + + // reload that token + tokReload, err := ds.GetABMTokenByOrgName(ctx, "org-name") + require.NoError(t, err) + require.Equal(t, tok.ID, tokReload.ID) + require.Equal(t, encTok, string(tokReload.EncryptedToken)) + require.Equal(t, "org-name", tokReload.OrganizationName) + require.Equal(t, "name@example.com", tokReload.AppleID) + require.Equal(t, tm1.Name, tokReload.MacOSTeamName) + require.Equal(t, tm1.Name, tokReload.MacOSTeam.Name) + require.Equal(t, tm1.ID, tokReload.MacOSTeam.ID) + require.Equal(t, tm2.Name, tokReload.IOSTeamName) + require.Equal(t, tm2.Name, tokReload.IOSTeam.Name) + require.Equal(t, tm2.ID, tokReload.IOSTeam.ID) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.IPadOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.IPadOSTeam.Name) + require.Equal(t, uint(0), tokReload.IPadOSTeam.ID) + + // empty name token now doesn't exist + _, err = ds.GetABMTokenByOrgName(ctx, "") + require.ErrorAs(t, err, &nfe) + + // update some teams + tok.MacOSDefaultTeamID = nil + tok.IPadOSDefaultTeamID = &tm3.ID + err = ds.SaveABMToken(ctx, tok) + require.NoError(t, err) + + // reload that token + tokReload, err = ds.GetABMTokenByOrgName(ctx, "org-name") + require.NoError(t, err) + require.Equal(t, tok.ID, tokReload.ID) + require.Equal(t, encTok, string(tokReload.EncryptedToken)) + require.Equal(t, "org-name", tokReload.OrganizationName) + require.Equal(t, "name@example.com", tokReload.AppleID) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.MacOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.MacOSTeam.Name) + require.Equal(t, uint(0), tokReload.MacOSTeam.ID) + require.Equal(t, tm2.Name, tokReload.IOSTeamName) + require.Equal(t, tm3.Name, tokReload.IPadOSTeamName) + + // change just the encrypted token + encTok2 := uuid.NewString() + tok.EncryptedToken = []byte(encTok2) + err = ds.SaveABMToken(ctx, tok) + require.NoError(t, err) + + tokReload, err = ds.GetABMTokenByOrgName(ctx, "org-name") + require.NoError(t, err) + require.Equal(t, tok.ID, tokReload.ID) + require.Equal(t, encTok2, string(tokReload.EncryptedToken)) + require.Equal(t, "org-name", tokReload.OrganizationName) + require.Equal(t, "name@example.com", tokReload.AppleID) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.MacOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, tokReload.MacOSTeam.Name) + require.Equal(t, uint(0), tokReload.MacOSTeam.ID) + require.Equal(t, tm2.Name, tokReload.IOSTeamName) + require.Equal(t, tm2.Name, tokReload.IOSTeam.Name) + require.Equal(t, tm2.ID, tokReload.IOSTeam.ID) + require.Equal(t, tm3.Name, tokReload.IPadOSTeamName) + require.Equal(t, tm3.Name, tokReload.IPadOSTeam.Name) + require.Equal(t, tm3.ID, tokReload.IPadOSTeam.ID) + + // Remove unused token + require.NoError(t, ds.DeleteABMToken(ctx, t1.ID)) + + toks, err = ds.ListABMTokens(ctx) + require.NoError(t, err) + require.Len(t, toks, 1) + expTok := toks[0] + require.Equal(t, "org-name", expTok.OrganizationName) + require.Equal(t, "name@example.com", expTok.AppleID) + require.Equal(t, fleet.TeamNameNoTeam, expTok.MacOSTeamName) + require.Equal(t, fleet.TeamNameNoTeam, expTok.MacOSTeam.Name) + require.Equal(t, uint(0), expTok.MacOSTeam.ID) + require.Equal(t, tm2.Name, expTok.IOSTeamName) + require.Equal(t, tm3.Name, expTok.IPadOSTeamName) +} + +func testMDMAppleABMTokensTermsExpired(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // count works with no token + count, err := ds.CountABMTokensWithTermsExpired(ctx) + require.NoError(t, err) + require.Zero(t, count) + + // create a few tokens + encTok1 := uuid.NewString() + t1, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "abm1", EncryptedToken: []byte(encTok1)}) + require.NoError(t, err) + require.NotEmpty(t, t1.ID) + encTok2 := uuid.NewString() + t2, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "abm2", EncryptedToken: []byte(encTok2)}) + require.NoError(t, err) + require.NotEmpty(t, t2.ID) + // this one simulates a mirated token - empty name + encTok3 := uuid.NewString() + t3, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "", EncryptedToken: []byte(encTok3)}) + require.NoError(t, err) + require.NotEmpty(t, t3.ID) + + // none have terms expired yet + count, err = ds.CountABMTokensWithTermsExpired(ctx) + require.NoError(t, err) + require.Zero(t, count) + + // set t1 terms expired + was, err := ds.SetABMTokenTermsExpiredForOrgName(ctx, t1.OrganizationName, true) + require.NoError(t, err) + require.False(t, was) + + // set t2 terms not expired, no-op + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, t2.OrganizationName, false) + require.NoError(t, err) + require.False(t, was) + + // set t3 terms expired + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, t3.OrganizationName, true) + require.NoError(t, err) + require.False(t, was) + + // count is now 2 + count, err = ds.CountABMTokensWithTermsExpired(ctx) + require.NoError(t, err) + require.EqualValues(t, 2, count) + + // set t1 terms not expired + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, t1.OrganizationName, false) + require.NoError(t, err) + require.True(t, was) + + // set t3 terms still expired + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, t3.OrganizationName, true) + require.NoError(t, err) + require.True(t, was) + + // count is now 1 + count, err = ds.CountABMTokensWithTermsExpired(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, count) + + // setting the expired flag of a non-existing token always returns as if it + // did not update (which is fine, it will only be called after a DEP API call + // that used this token, so if the token does not exist it would fail the + // call). + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, "no-such-token", false) + require.NoError(t, err) + require.False(t, was) + was, err = ds.SetABMTokenTermsExpiredForOrgName(ctx, "no-such-token", true) + require.NoError(t, err) + require.True(t, was) + + // count is unaffected + count, err = ds.CountABMTokensWithTermsExpired(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, count) +} + +func testMDMGetABMTokenOrgNamesAssociatedWithTeam(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create some teams + tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + encTok := uuid.NewString() + + tok1, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "org1", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, tok1.ID) + + tok2, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "org2", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, tok1.ID) + + tok3, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "org3", EncryptedToken: []byte(encTok), MacOSDefaultTeamID: &tm2.ID}) + require.NoError(t, err) + require.NotEmpty(t, tok1.ID) + + // Create some hosts and add to teams (and one for no team) + h1, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host1-name", + OsqueryHostID: ptr.String("1"), + NodeKey: ptr.String("1"), + UUID: "test-uuid-1", + TeamID: &tm1.ID, + Platform: "darwin", + }) + require.NoError(t, err) + + h2, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host2-name", + OsqueryHostID: ptr.String("2"), + NodeKey: ptr.String("2"), + UUID: "test-uuid-2", + TeamID: &tm1.ID, + Platform: "darwin", + }) + require.NoError(t, err) + + h3, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host3-name", + OsqueryHostID: ptr.String("3"), + NodeKey: ptr.String("3"), + UUID: "test-uuid-3", + TeamID: nil, + Platform: "darwin", + }) + require.NoError(t, err) + + h4, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host4-name", + OsqueryHostID: ptr.String("4"), + NodeKey: ptr.String("4"), + UUID: "test-uuid-4", + TeamID: &tm1.ID, + Platform: "darwin", + }) + require.NoError(t, err) + + // Insert host DEP assignment + require.NoError(t, ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h1, *h4}, tok1.ID)) + require.NoError(t, ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h2}, tok3.ID)) + require.NoError(t, ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h3}, tok2.ID)) + + // Should return the 2 unique org names [org1, org3] + orgNames, err := ds.GetABMTokenOrgNamesAssociatedWithTeam(ctx, &tm1.ID) + require.NoError(t, err) + sort.Strings(orgNames) + require.Len(t, orgNames, 2) + require.Equal(t, orgNames[0], "org1") + require.Equal(t, orgNames[1], "org3") + + // all tokens default to no team in one way or another + orgNames, err = ds.GetABMTokenOrgNamesAssociatedWithTeam(ctx, nil) + require.NoError(t, err) + sort.Strings(orgNames) + require.Len(t, orgNames, 3) + require.Equal(t, orgNames[0], "org1") + require.Equal(t, orgNames[1], "org2") + require.Equal(t, orgNames[2], "org3") + + // No orgs for this team except org3 which uses it as a default team + orgNames, err = ds.GetABMTokenOrgNamesAssociatedWithTeam(ctx, &tm2.ID) + require.NoError(t, err) + sort.Strings(orgNames) + require.Len(t, orgNames, 1) + require.Equal(t, orgNames[0], "org3") +} + +func testHostMDMCommands(t *testing.T, ds *Datastore) { + ctx := context.Background() + + addHostMDMCommandsBatchSizeOrig := addHostMDMCommandsBatchSize + addHostMDMCommandsBatchSize = 2 + t.Cleanup(func() { + addHostMDMCommandsBatchSize = addHostMDMCommandsBatchSizeOrig + }) + + // create a host + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("host0-osquery-id"), + NodeKey: ptr.String("host0-node-key"), + UUID: "host0-test-mdm-profiles", + Hostname: "hostname0", + }) + require.NoError(t, err) + + hostCommands := []fleet.HostMDMCommand{ + { + HostID: h.ID, + CommandType: "command-1", + }, + { + HostID: h.ID, + CommandType: "command-2", + }, + { + HostID: h.ID, + CommandType: "command-3", + }, + } + + badHostID := h.ID + 1 + allCommands := append(hostCommands, fleet.HostMDMCommand{ + HostID: badHostID, + CommandType: "command-1", + }) + err = ds.AddHostMDMCommands(ctx, allCommands) + require.NoError(t, err) + + commands, err := ds.GetHostMDMCommands(ctx, h.ID) + require.NoError(t, err) + assert.ElementsMatch(t, hostCommands, commands) + + // Remove a command + require.NoError(t, ds.RemoveHostMDMCommand(ctx, hostCommands[0])) + + commands, err = ds.GetHostMDMCommands(ctx, h.ID) + require.NoError(t, err) + assert.ElementsMatch(t, hostCommands[1:], commands) + + // Clean up commands, and make sure badHost commands have been removed, but others remain. + commands, err = ds.GetHostMDMCommands(ctx, badHostID) + require.NoError(t, err) + assert.Len(t, commands, 1) + + require.NoError(t, ds.CleanupHostMDMCommands(ctx)) + commands, err = ds.GetHostMDMCommands(ctx, badHostID) + require.NoError(t, err) + assert.Empty(t, commands) + + commands, err = ds.GetHostMDMCommands(ctx, h.ID) + require.NoError(t, err) + assert.ElementsMatch(t, hostCommands[1:], commands) +} + +func testIngestMDMAppleDeviceFromOTAEnrollment(t *testing.T, ds *Datastore) { + ctx := context.Background() + createBuiltinLabels(t, ds) + + for i := 0; i < 10; i++ { + _, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: fmt.Sprintf("hostname_%d", i), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-time.Duration(i) * time.Minute), + OsqueryHostID: ptr.String(fmt.Sprintf("osquery-host-id_%d", i)), + NodeKey: ptr.String(fmt.Sprintf("node-key_%d", i)), + UUID: fmt.Sprintf("uuid_%d", i), + HardwareSerial: fmt.Sprintf("serial_%d", i), + }) + require.NoError(t, err) + } + + hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 10) + wantSerials := []string{} + for _, h := range hosts { + wantSerials = append(wantSerials, h.HardwareSerial) + } + + // mock results incoming from OTA enrollments + otaDevices := []fleet.MDMAppleMachineInfo{ + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: "abc", Product: "MacBook Pro"}, + {Serial: hosts[0].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "ijk", Product: "iPad13,16"}, + {Serial: "tuv", Product: "iPhone14,6"}, + {Serial: hosts[1].HardwareSerial, Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + {Serial: "xyz", Product: "MacBook Pro"}, + } + wantSerials = append(wantSerials, "abc", "xyz", "ijk", "tuv") + + for _, d := range otaDevices { + err := ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, nil, d) + require.NoError(t, err) + } + + hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, len(wantSerials)) + gotSerials := []string{} + for _, h := range hosts { + gotSerials = append(gotSerials, h.HardwareSerial) + + switch h.HardwareSerial { + case "abc", "xyz": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "MacBook Pro") + case "ijk": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPad13,16") + case "tuv": + checkMDMHostRelatedTables(t, ds, h.ID, h.HardwareSerial, "iPhone14,6") + + } + } + require.ElementsMatch(t, wantSerials, gotSerials) +} + +func TestGetMDMAppleOSUpdatesSettingsByHostSerial(t *testing.T) { + ds := CreateMySQLDS(t) + defer ds.Close() + + keys := []string{"ios", "ipados", "macos"} + devicesByKey := map[string]godep.Device{ + "ios": {SerialNumber: "dep-serial-ios-updates", DeviceFamily: "iPhone"}, + "ipados": {SerialNumber: "dep-serial-ipados-updates", DeviceFamily: "iPad"}, + "macos": {SerialNumber: "dep-serial-macos-updates", DeviceFamily: "Mac"}, + } + + getConfigSettings := func(teamID uint, key string) *fleet.AppleOSUpdateSettings { + var settings fleet.AppleOSUpdateSettings + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := fmt.Sprintf(`SELECT json_value->'$.mdm.%s_updates' FROM app_config_json`, key) + if teamID > 0 { + stmt = fmt.Sprintf(`SELECT config->'$.mdm.%s_updates' FROM teams WHERE id = %d`, key, teamID) + } + var raw json.RawMessage + if err := sqlx.GetContext(context.Background(), q, &raw, stmt); err != nil { + return err + } + if err := json.Unmarshal(raw, &settings); err != nil { + return err + } + return nil + }) + return &settings + } + + setConfigSettings := func(teamID uint, key string, minVersion string) { + var mv *string + if minVersion != "" { + mv = &minVersion + } + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + stmt := fmt.Sprintf(`UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.%s_updates.minimum_version', ?)`, key) + if teamID > 0 { + stmt = fmt.Sprintf(`UPDATE teams SET config = JSON_SET(config, '$.mdm.%s_updates.minimum_version', ?) WHERE id = %d`, key, teamID) + } + if _, err := q.ExecContext(context.Background(), stmt, mv); err != nil { + return err + } + return nil + }) + } + + checkExpectedVersion := func(t *testing.T, gotSettings *fleet.AppleOSUpdateSettings, expectedVersion string) { + if expectedVersion == "" { + require.True(t, gotSettings.MinimumVersion.Set) + require.False(t, gotSettings.MinimumVersion.Valid) + require.Empty(t, gotSettings.MinimumVersion.Value) + } else { + require.True(t, gotSettings.MinimumVersion.Set) + require.True(t, gotSettings.MinimumVersion.Valid) + require.Equal(t, expectedVersion, gotSettings.MinimumVersion.Value) + } + } + + checkDevice := func(t *testing.T, teamID uint, key string, wantVersion string) { + checkExpectedVersion(t, getConfigSettings(teamID, key), wantVersion) + gotSettings, err := ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey[key].SerialNumber) + require.NoError(t, err) + checkExpectedVersion(t, gotSettings, wantVersion) + } + + // empty global settings to start + for _, key := range keys { + checkExpectedVersion(t, getConfigSettings(0, key), "") + } + + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + + // ingest some test devices + n, err := ds.IngestMDMAppleDevicesFromDEPSync(context.Background(), []godep.Device{devicesByKey["ios"], devicesByKey["ipados"], devicesByKey["macos"]}, abmToken.ID, nil, nil, nil) + require.NoError(t, err) + require.Equal(t, int64(3), n) + hostIDsByKey := map[string]uint{} + for key, device := range devicesByKey { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + var hid uint + err = sqlx.GetContext(context.Background(), q, &hid, "SELECT id FROM hosts WHERE hardware_serial = ?", device.SerialNumber) + require.NoError(t, err) + hostIDsByKey[key] = hid + return nil + }) + } + + // not set in global config, so devics should return empty + checkDevice(t, 0, "ios", "") + checkDevice(t, 0, "ipados", "") + checkDevice(t, 0, "macos", "") + + // set the minimum version for ios + setConfigSettings(0, "ios", "17.1") + checkDevice(t, 0, "ios", "17.1") + checkDevice(t, 0, "ipados", "") // no change + checkDevice(t, 0, "macos", "") // no change + + // set the minimum version for ipados + setConfigSettings(0, "ipados", "17.2") + checkDevice(t, 0, "ios", "17.1") // no change + checkDevice(t, 0, "ipados", "17.2") + checkDevice(t, 0, "macos", "") // no change + + // set the minimum version for macos + setConfigSettings(0, "macos", "14.5") + checkDevice(t, 0, "ios", "17.1") // no change + checkDevice(t, 0, "ipados", "17.2") // no change + checkDevice(t, 0, "macos", "14.5") + + // create a team + team, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + // empty team settings to start + for _, key := range keys { + checkExpectedVersion(t, getConfigSettings(team.ID, key), "") + } + + // transfer ios and ipados to the team + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["ios"], hostIDsByKey["ipados"]}) + require.NoError(t, err) + + checkDevice(t, team.ID, "ios", "") // team settings are empty to start + checkDevice(t, team.ID, "ipados", "") // team settings are empty to start + checkDevice(t, 0, "macos", "14.5") // no change, still global + + setConfigSettings(team.ID, "ios", "17.3") + checkDevice(t, team.ID, "ios", "17.3") // team settings are set for ios + checkDevice(t, team.ID, "ipados", "") // team settings are empty for ipados + checkDevice(t, 0, "macos", "14.5") // no change, still global + + setConfigSettings(team.ID, "ipados", "17.4") + checkDevice(t, team.ID, "ios", "17.3") // no change in team settings for ios + checkDevice(t, team.ID, "ipados", "17.4") // team settings are set for ipados + checkDevice(t, 0, "macos", "14.5") // no change, still global + + // transfer macos to the team + err = ds.AddHostsToTeam(context.Background(), &team.ID, []uint{hostIDsByKey["macos"]}) + require.NoError(t, err) + checkDevice(t, team.ID, "macos", "") // team settings are empty for macos + + setConfigSettings(team.ID, "macos", "14.6") + checkDevice(t, team.ID, "macos", "14.6") // team settings are set for macos + + // create a non-DEP host + _, err = ds.NewHost(context.Background(), &fleet.Host{ + OsqueryHostID: ptr.String("non-dep-osquery-id"), + NodeKey: ptr.String("non-dep-node-key"), + UUID: "non-dep-uuid", + Hostname: "non-dep-hostname", + Platform: "macos", + HardwareSerial: "non-dep-serial", + }) + + // non-DEP host should return not found + _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), "non-dep-serial") + require.ErrorIs(t, err, sql.ErrNoRows) + + // deleted DEP host should return not found + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(context.Background(), "UPDATE host_dep_assignments SET deleted_at = NOW() WHERE host_id = ?", hostIDsByKey["macos"]) + return err + }) + _, err = ds.GetMDMAppleOSUpdatesSettingsByHostSerial(context.Background(), devicesByKey["macos"].SerialNumber) + require.ErrorIs(t, err, sql.ErrNoRows) +} diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go index af8200b2b0..21004cf83a 100644 --- a/server/datastore/mysql/calendar_events.go +++ b/server/datastore/mysql/calendar_events.go @@ -64,7 +64,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent( return ctxerr.Wrap(ctx, err, "insert calendar event") } - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { id, _ = result.LastInsertId() } else { stmt := `SELECT id FROM calendar_events WHERE email = ?` diff --git a/server/datastore/mysql/errors.go b/server/datastore/mysql/errors.go index 673a4701fc..fa9bbac282 100644 --- a/server/datastore/mysql/errors.go +++ b/server/datastore/mysql/errors.go @@ -191,6 +191,12 @@ func isMySQLAccessDenied(err error) bool { return false } +func isMySQLUnknownStatement(err error) bool { + err = ctxerr.Cause(err) + var mySQLErr *mysql.MySQLError + return errors.As(err, &mySQLErr) && (mySQLErr.Number == mysqlerr.ER_UNKNOWN_STMT_HANDLER) +} + // ErrPartialResult indicates that a batch operation was completed, // but some of the results are missing or incomplete. var ErrPartialResult = errors.New("batch operation completed with partial results") diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index fb4b88f077..0b3a0e4983 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -1042,7 +1042,7 @@ func (ds *Datastore) applyHostFilters( meta, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false) switch { case fleet.IsNotFound(err): - vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false) + vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id") } @@ -2147,14 +2147,27 @@ func (ds *Datastore) EnrollHost(ctx context.Context, isMDMEnabled bool, osqueryH // to update their MySQL configurations when additional prepare statements are added. // For more detail, see: https://github.com/fleetdm/fleet/issues/15476 func (ds *Datastore) getContextTryStmt(ctx context.Context, dest interface{}, query string, args ...interface{}) error { - var err error // nolint the statements are closed in Datastore.Close. if stmt := ds.loadOrPrepareStmt(ctx, query); stmt != nil { - err = stmt.GetContext(ctx, dest, args...) - } else { - err = sqlx.GetContext(ctx, ds.reader(ctx), dest, query, args...) + err := stmt.GetContext(ctx, dest, args...) + if err == nil || !isMySQLUnknownStatement(err) { + return err + } + + // if the statement is unknown to the MySQL server, delete it + // from the cache and fallback to the regular statement call + // bellow. This function will get called again eventually and + // we will store a new prepared statement in the cache. + // + // - see https://github.com/fleetdm/fleet/issues/20781 for an + // example of when this can happen. + // + // - see https://github.com/go-sql-driver/mysql/issues/1555 for + // a related open bug in the driver + ds.deleteCachedStmt(query) } - return err + + return sqlx.GetContext(ctx, ds.reader(ctx), dest, query, args...) } // LoadHostByNodeKey loads the whole host identified by the node key. @@ -2961,7 +2974,7 @@ func (ds *Datastore) ListPoliciesForHost(ctx context.Context, host *fleet.Host) FROM policies p LEFT JOIN policy_membership pm ON (p.id=pm.policy_id AND host_id=?) LEFT JOIN users u ON p.author_id = u.id - WHERE (p.team_id IS NULL OR p.team_id = (select team_id from hosts WHERE id = ?)) + WHERE (p.team_id IS NULL OR p.team_id = COALESCE((SELECT team_id FROM hosts WHERE id = ?), 0)) AND (p.platforms IS NULL OR p.platforms = '' OR FIND_IN_SET(?, p.platforms) != 0) ORDER BY FIELD(response, 'fail', '', 'pass'), p.name` @@ -3642,6 +3655,18 @@ func (ds *Datastore) SetOrUpdateMDMData( ) } +func (ds *Datastore) UpdateMDMData( + ctx context.Context, + hostID uint, + enrolled bool, +) error { + _, err := ds.writer(ctx).ExecContext(ctx, `UPDATE host_mdm SET enrolled = ? WHERE host_id = ?`, enrolled, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "update host_mdm.enrolled") + } + return nil +} + func (ds *Datastore) SetOrUpdateHostEmailsFromMdmIdpAccounts( ctx context.Context, hostID uint, @@ -5022,6 +5047,18 @@ func amountHostsByOsqueryVersionDB(ctx context.Context, db sqlx.QueryerContext) return counts, nil } +func numHostsFleetDesktopEnabledDB(ctx context.Context, db sqlx.QueryerContext) (int, error) { + var count int + const stmt = ` + SELECT count(*) FROM host_orbit_info WHERE desktop_version IS NOT NULL + ` + if err := sqlx.GetContext(ctx, db, &count, stmt); err != nil { + return 0, err + } + + return count, nil +} + func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []string) (map[string]*fleet.Host, error) { result := map[string]*fleet.Host{} if len(serials) == 0 { diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 379adf166d..2edc5ab393 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -1245,13 +1245,17 @@ func testHostsListMDM(t *testing.T, ds *Datastore) { hostIDs = append(hostIDs, h.ID) } + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + // enrollment: pending (with Fleet mdm) - n, tmID, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ + n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{ {SerialNumber: "532141num832", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, - }) + }, abmToken.ID, nil, nil, nil) require.NoError(t, err) require.Equal(t, int64(1), n) - require.Nil(t, tmID) const simpleMDM, kandji, unknown = "https://simplemdm.com", "https://kandji.io", "https://url.com" err = ds.SetOrUpdateMDMData(ctx, hostIDs[0], false, true, simpleMDM, true, fleet.WellKnownMDMSimpleMDM, "") // enrollment: automatic @@ -6683,7 +6687,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { Sha256: sha256.New().Sum(nil), Bytes: []byte("content"), Token: uuid.New().String(), - }) + }, nil) require.NoError(t, err) err = ds.RecordHostBootstrapPackage(context.Background(), "command-uuid", host.UUID) require.NoError(t, err) @@ -6747,6 +6751,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { InstallScript: "", PreInstallQuery: "", Title: "ChocolateRain", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false) @@ -7555,6 +7560,11 @@ func testHostsGetHostMDMCheckinInfo(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + host, err := ds.NewHost(context.Background(), &fleet.Host{ DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), @@ -7583,7 +7593,7 @@ func testHostsGetHostMDMCheckinInfo(t *testing.T, ds *Datastore) { require.True(t, info.OsqueryEnrolled) require.Equal(t, "darwin", info.Platform) - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*host}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*host}, abmToken.ID) require.NoError(t, err) info, err = ds.GetHostMDMCheckinInfo(ctx, host.UUID) require.NoError(t, err) @@ -7610,6 +7620,11 @@ func testHostsGetHostMDMCheckinInfo(t *testing.T, ds *Datastore) { func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { ctx := context.Background() + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + for _, tt := range enrollTests { h, err := ds.EnrollHost(ctx, false, tt.uuid, tt.uuid, "", tt.nodeKey, nil, 0) require.NoError(t, err) @@ -7636,7 +7651,7 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { } // test loading an unknown orbit key - _, err := ds.LoadHostByOrbitNodeKey(ctx, uuid.New().String()) + _, err = ds.LoadHostByOrbitNodeKey(ctx, uuid.New().String()) require.Error(t, err) require.True(t, fleet.IsNotFound(err)) @@ -7710,7 +7725,7 @@ func testHostsLoadHostByOrbitNodeKey(t *testing.T, ds *Datastore) { require.True(t, *loadFleet.DiskEncryptionEnabled) // simulate the device being assigned to Fleet in ABM - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*hFleet}) + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*hFleet}, abmToken.ID) require.NoError(t, err) loadFleet, err = ds.LoadHostByOrbitNodeKey(ctx, *hFleet.OrbitNodeKey) require.NoError(t, err) diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 2f654ebe33..d604b286a4 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -227,6 +227,14 @@ func (ds *Datastore) SaveLabel(ctx context.Context, label *fleet.Label, teamFilt if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "saving label") } + + // Update the label name in mdm_configuration_profile_labels + query = `UPDATE mdm_configuration_profile_labels SET label_name = ? WHERE label_id = ?` + _, err = ds.writer(ctx).ExecContext(ctx, query, label.Name, label.ID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "updating mdm configuration profile label") + } + return ds.labelDB(ctx, label.ID, teamFilter, ds.writer(ctx)) } diff --git a/server/datastore/mysql/labels_test.go b/server/datastore/mysql/labels_test.go index 17d97278cd..ca01edfdda 100644 --- a/server/datastore/mysql/labels_test.go +++ b/server/datastore/mysql/labels_test.go @@ -673,6 +673,31 @@ func testLabelsChangeDetails(t *testing.T, db *Datastore) { saved, _, err := db.Label(context.Background(), label.ID, filter) require.Nil(t, err) assert.Equal(t, label.Name, saved.Name) + assert.Equal(t, label.Description, saved.Description) + + // Create an Apple config profile, which should reflect a change in label's name + profA, err := db.NewMDMAppleConfigProfile(context.Background(), *generateCP("a", "a", 0)) + require.NoError(t, err) + ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(context.Background(), + "INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)", + profA.ProfileUUID, label.Name, label.ID) + return err + }) + label.Name = "changed name" + // ApplyLabelSpecs can't update the name -- it simply creates a new label, so we need to call SaveLabel. + saved.Name = label.Name + saved2, _, err := db.SaveLabel(context.Background(), saved, filter) + require.NoError(t, err) + assert.Equal(t, label.Name, saved2.Name) + assert.Equal(t, label.Description, saved2.Description) + + var configProfileLabelName string + ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &configProfileLabelName, + "SELECT label_name FROM mdm_configuration_profile_labels WHERE label_id = ?", label.ID) + }) + assert.Equal(t, label.Name, configProfileLabelName) } func setupLabelSpecsTest(t *testing.T, ds fleet.Datastore) []*fleet.LabelSpec { @@ -806,6 +831,17 @@ func testLabelsSave(t *testing.T, db *Datastore) { } label, err = db.NewLabel(context.Background(), label) require.NoError(t, err) + + // Create an Apple config profile + profA, err := db.NewMDMAppleConfigProfile(context.Background(), *generateCP("a", "a", 0)) + require.NoError(t, err) + ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(context.Background(), + "INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)", + profA.ProfileUUID, label.Name, label.ID) + return err + }) + label.Name = "changed name" label.Description = "changed description" @@ -819,6 +855,13 @@ func testLabelsSave(t *testing.T, db *Datastore) { assert.Equal(t, label.Name, saved.Name) assert.Equal(t, label.Description, saved.Description) assert.Equal(t, 1, saved.HostCount) + + var configProfileLabelName string + ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &configProfileLabelName, + "SELECT label_name FROM mdm_configuration_profile_labels WHERE label_id = ?", label.ID) + }) + assert.Equal(t, label.Name, configProfileLabelName) } func testLabelsQueriesForCentOSHost(t *testing.T, db *Datastore) { diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index eefce6091d..d3bbba82de 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -13,6 +13,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/jmoiron/sqlx" ) @@ -121,22 +122,26 @@ func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, c return &cmd, nil } -func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - if err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { +func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, + err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + if updates.WindowsConfigProfile, err = ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set windows profiles") } - if err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { + if updates.AppleConfigProfile, err = ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple profiles") } - if _, err := ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { + if _, updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple declarations") } return nil }) + return updates, err } func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { @@ -335,10 +340,12 @@ func (ds *Datastore) BulkSetPendingMDMHostProfiles( ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) +) (updates fleet.MDMProfilesUpdates, err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + updates, err = ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) + return err }) + return updates, err } // Note that team ID 0 is used for profiles that apply to hosts in no team @@ -349,7 +356,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( tx sqlx.ExtContext, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { +) (updates fleet.MDMProfilesUpdates, err error) { var ( countArgs int macProfUUIDs []string @@ -384,10 +391,10 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countArgs++ } if countArgs > 1 { - return errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") + return updates, errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") } if countArgs == 0 { - return nil + return updates, nil } var countProfUUIDs int @@ -401,7 +408,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countProfUUIDs++ } if countProfUUIDs > 1 { - return errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") + return updates, errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") } var ( @@ -471,10 +478,10 @@ WHERE if len(hosts) == 0 && !hasAppleDecls { uuidStmt, args, err := sqlx.In(uuidStmt, args...) if err != nil { - return ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") } if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") } } @@ -495,12 +502,14 @@ WHERE } } - if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") + updates.AppleConfigProfile, err = ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } - if err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") + updates.WindowsConfigProfile, err = ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } const defaultBatchSize = 1000 @@ -513,11 +522,12 @@ WHERE // (and my hunch is that we could even do the same for // profiles) but this could be optimized to use only a provided // set of host uuids. - if _, err := mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") + _, updates.AppleDeclaration, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") } - return nil + return updates, nil } func (ds *Datastore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify, toFail, toRetry []string) error { @@ -984,9 +994,9 @@ func batchSetProfileLabelAssociationsDB( tx sqlx.ExtContext, profileLabels []fleet.ConfigurationProfileLabel, platform string, -) error { +) (updatedDB bool, err error) { if len(profileLabels) == 0 { - return nil + return false, nil } var platformPrefix string @@ -1001,7 +1011,7 @@ func batchSetProfileLabelAssociationsDB( case "windows": platformPrefix = "windows" default: - return fmt.Errorf("unsupported platform %s", platform) + return false, fmt.Errorf("unsupported platform %s", platform) } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -1023,38 +1033,72 @@ func batchSetProfileLabelAssociationsDB( exclude = VALUES(exclude) ` + selectStmt := ` + SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels + WHERE (%s_profile_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) ) + labelsToInsert := make(map[string]*fleet.ConfigurationProfileLabel, len(profileLabels)) for i, pl := range profileLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &profileLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, platformPrefix, platformPrefix, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) - } - - return ctxerr.Wrap(ctx, err, "setting label associations for profile") + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") } - deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, deleteBuilder.String(), platformPrefix) + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) + } + + return false, ctxerr.Wrap(ctx, err, "setting label associations for profile") + } + updatedDB = true + } + + deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, selectOrDeleteBuilder.String(), platformPrefix) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -1064,13 +1108,18 @@ func batchSetProfileLabelAssociationsDB( deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for profiles") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) { diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 2f0d294121..a8d765baca 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -358,13 +359,15 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { wantApple []*fleet.MDMAppleConfigProfile, wantWindows []*fleet.MDMWindowsConfigProfile, wantAppleDecl []*fleet.MDMAppleDeclaration, + wantUpdates fleet.MDMProfilesUpdates, ) { ctx := context.Background() - err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) + updates, err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) require.NoError(t, err) expectAppleProfiles(t, ds, tmID, wantApple) expectWindowsProfiles(t, ds, tmID, wantWindows) expectAppleDeclarations(t, ds, tmID, wantAppleDecl) + assert.Equal(t, wantUpdates, updates) } withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { @@ -383,7 +386,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { } // empty set for no team (both Apple and Windows) - applyAndExpect(nil, nil, nil, nil, nil, nil, nil) + applyAndExpect(nil, nil, nil, nil, nil, nil, nil, fleet.MDMProfilesUpdates{}) // single Apple and Windows profile set for a specific team applyAndExpect( @@ -398,6 +401,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1), }, []*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // single Apple and Windows profile set for no team @@ -409,6 +413,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { []*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")}, []*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")}, []*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profile sets for a specific team @@ -438,6 +443,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D1", "D1", "foo"), 1), withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // edited profiles, unchanged profiles, and new profiles for a specific team @@ -473,6 +479,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), withTeamIDDecl(declForTest("D3", "D3", "bar"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profiles to no team @@ -502,10 +509,43 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { declForTest("D5", "D4", "foo"), declForTest("D4", "D5", "foo"), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) + + // Apply the same profiles again -- no update should be detected + applyAndExpect( + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + nil, + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: false}, ) // Test Case 8: Clear profiles for a specific team - applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil) + applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) } func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { @@ -1063,17 +1103,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // bulk set for no target ids, does nothing - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) + // bulk set for combination of target ids, not allowed - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) + _, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) require.Error(t, err) // bulk set for all created hosts, no profiles yet so nothing changed allHosts := append(darwinHosts, unenrolledHost, linuxHost) allHosts = append(allHosts, windowsHosts...) - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: {}, darwinHosts[1]: {}, @@ -1100,7 +1147,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G2w", "L2"), windowsConfigProfileForTest(t, "G3w", "L3"), } - err = ds.BatchSetMDMProfiles( + updates, err = ds.BatchSetMDMProfiles( ctx, nil, macGlobalProfiles, @@ -1113,6 +1160,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, macGlobalProfiles, 3) globalProfiles := getProfs(nil) require.Len(t, globalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) // list profiles to install, should result in the global profiles for all // enrolled hosts @@ -1132,8 +1182,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // bulk set for all created hosts, enrolled hosts get the no-team profiles - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1311,7 +1364,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[0], windowsHosts[0]), nil, @@ -1319,6 +1372,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1482,7 +1538,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host via its uuid (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -1490,6 +1546,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { []string{darwinHosts[1].UUID, windowsHosts[1].UUID}, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1620,8 +1679,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.1w", "T1.1"), windowsConfigProfileForTest(t, "T1.2w", "T1.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) tm1Profiles := getProfs(&team1.ID) require.Len(t, tm1Profiles, 4) @@ -1644,8 +1706,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1827,15 +1892,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) newTm1Profiles := getProfs(&team1.ID) require.Len(t, newTm1Profiles, 4) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -1974,6 +2045,13 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }, }) + // update again -- nothing should change + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + // re-add tm1Profiles[0] to list of team1 profiles (T1.1 on Apple, T1.2 on Windows) // NOTE: even though it is the same profile, it's unique DB ID is different because // it got deleted and re-inserted from the team's profiles, so this is reflected in @@ -1989,14 +2067,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) newTm1Profiles = getProfs(&team1.ID) require.Len(t, newTm1Profiles, 6) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2154,15 +2238,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // TODO(roberto): add new darwin declarations for this and all subsequent assertions - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) newGlobalProfiles := getProfs(nil) require.Len(t, newGlobalProfiles, 6) // update status of the affected "no-team" - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[0].UUID, nil)) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[1].UUID, nil)) @@ -2289,15 +2379,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G5w", "G5"), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // bulk-set only those affected by the new Apple global profile newDarwinProfileUUID := newGlobalProfiles[3].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2407,8 +2503,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // bulk-set only those affected by the new Apple global profile newWindowsProfileUUID := newGlobalProfiles[7].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2531,14 +2630,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.1w", "T2.1"), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles := getProfs(&team2.ID) require.Len(t, tm2Profiles, 2) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status via tm2 id and the global 0 id to test that custom sql statement - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2714,7 +2819,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G7w", "G7", labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 12) @@ -2723,6 +2828,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[5], labels[2]) setProfileLabels(t, newGlobalProfiles[10], labels[3], labels[4]) setProfileLabels(t, newGlobalProfiles[11], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // simulate an entry with some values set to NULL ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -2737,7 +2845,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a sync of all hosts, should not change anything as no host is a member // of the new label-based profiles (indices change due to new Apple and // Windows profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2746,6 +2854,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2912,7 +3023,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a full sync, the new global hosts get the standard global profiles and // also the label-based profile that they are a member of - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2921,6 +3032,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3117,7 +3231,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // do a sync of those hosts, they will get the two label-based profiles of their platform - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3125,6 +3239,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3327,7 +3444,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, ds.DeleteLabel(ctx, labels[3].Name)) // sync the affected profiles - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3335,7 +3452,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3343,6 +3463,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // nothing changes - broken label-based profiles are simply ignored assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -3551,7 +3674,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3559,6 +3682,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3756,7 +3882,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[4], labels[1]) setProfileLabels(t, newGlobalProfiles[10], labels[4]) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3764,7 +3890,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3772,6 +3901,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3969,18 +4101,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.2w", "T2.2", labels[4], labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles = getProfs(&team2.ID) require.Len(t, tm2Profiles, 4) // TODO(mna): temporary until BatchSetMDMProfiles supports labels setProfileLabels(t, tm2Profiles[1], labels[1], labels[2]) setProfileLabels(t, tm2Profiles[3], labels[4], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // sync team 2, no changes because no host is a member of the labels (except // index change due to new profiles) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4178,8 +4316,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // sync team 2, the label-based profile of team2 is now pending install - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4388,8 +4529,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is left untouched (broken // profiles are ignored) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4603,8 +4747,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is still left untouched // because even if the hosts are not members anymore, the profile is broken - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4808,8 +4955,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, now it sees that the hosts are not members and the profile // gets removed - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5003,7 +5153,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) // sanity-check, a full sync does not change anything - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -5012,6 +5162,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5237,8 +5390,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) configProfileForTest(t, "T1.2", "T1.2", "e"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5275,8 +5431,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_1"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5354,8 +5513,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_3"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5444,8 +5606,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel4, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_4"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5524,8 +5689,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) windowsConfigProfileForTest(t, "T5.2", "T5.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5562,8 +5730,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_6"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5641,8 +5812,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5731,8 +5905,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5947,18 +6124,16 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { wantOtherWin := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherWinProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"), - ) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows") + require.NoError(t, err) + assert.True(t, updatedDB) // make it an "exclude" label on the other macos profile wantOtherMac := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin"), - ) + updatedDB, err = batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin") + require.NoError(t, err) + assert.True(t, updatedDB) platforms := map[string]string{ "darwin": macOSProfile.ProfileUUID, @@ -5991,7 +6166,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("empty input "+platform, func(t *testing.T) { want := []fleet.ConfigurationProfileLabel{} err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + require.NoError(t, err) + assert.False(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, want) @@ -6005,7 +6183,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6018,7 +6199,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6033,7 +6217,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6044,7 +6229,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: 12345}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) @@ -6053,7 +6239,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: "xyz", LabelID: 1235}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6074,7 +6261,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) // both are stored in the DB @@ -6085,7 +6275,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6098,12 +6291,13 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("unsupported platform", func(t *testing.T) { err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB( + _, err := batchSetProfileLabelAssociationsDB( ctx, tx, []fleet.ConfigurationProfileLabel{{}}, "unsupported", ) + return err }) require.Error(t, err) }) @@ -6185,7 +6379,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "W2", "l2"), } // set the initial profiles without error - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.NoError(t, err) // now ensure all steps are required (add a profile, delete a profile, set labels) @@ -6201,7 +6395,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { ds.testBatchSetMDMAppleProfilesErr = c.appleErr ds.testBatchSetMDMWindowsProfilesErr = c.windowsErr - err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.ErrorContains(t, err, c.wantErr) }) } @@ -7139,8 +7333,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { declForTest("D1", "D1", "{}", labels[3], labels[4], labels[5]), } - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) + updates, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) // must reload them to get the profile/declaration uuid getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload { @@ -7185,8 +7382,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { // do a sync, they get all platform-specific profiles since they are not part // of any label - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7225,8 +7425,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7257,8 +7460,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7293,8 +7499,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { err = ds.DeleteLabel(ctx, labels[3].Name) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to remove" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -7345,8 +7554,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnroll(t, ds, appleHost2, false) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to install" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index b7426bff00..112d60b868 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -172,7 +172,7 @@ func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx s 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) -VALUES ((SELECT id FROM mdm_windows_enrollments WHERE host_uuid = ? OR mdm_device_id = ?), ?) +VALUES ((SELECT id FROM mdm_windows_enrollments WHERE host_uuid = ? OR mdm_device_id = ? ORDER BY created_at DESC LIMIT 1), ?) ` if _, err := tx.ExecContext(ctx, stmt, hostUUIDOrDeviceID, hostUUIDOrDeviceID, commandUUID); err != nil { @@ -1581,7 +1581,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } @@ -1653,7 +1653,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMWindowsConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT name, @@ -1721,13 +1721,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1748,40 +1748,48 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepNames) + var result sql.Result if len(keepNames) > 0 { stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, keepNames) if err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") } } else { - if _, err := tx.ExecContext(ctx, deleteAllProfilesForTeam, profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, deleteAllProfilesForTeam, + profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete all profiles for team") + return false, ctxerr.Wrap(ctx, err, "delete all profiles for team") } } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 + } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, + p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1797,19 +1805,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Name] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1825,47 +1833,56 @@ ON DUPLICATE KEY UPDATE } // insert/delete the label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } profilesToInstall, err := listMDMWindowsProfilesToInstallDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to install") + return false, ctxerr.Wrap(ctx, err, "list profiles to install") } profilesToRemove, err := listMDMWindowsProfilesToRemoveDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to remove") + return false, ctxerr.Wrap(ctx, err, "list profiles to remove") } if len(profilesToInstall) == 0 && len(profilesToRemove) == 0 { - return nil + return false, nil } - if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + if len(profilesToRemove) > 0 { + if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + } + updatedDB = true + } + if len(profilesToInstall) == 0 { + return updatedDB, nil } var ( - pargs []any - psb strings.Builder - batchCount int + pargs []any + profilesToInsert = make(map[string]*fleet.MDMWindowsProfilePayload) + psb strings.Builder + batchCount int ) const defaultBatchSize = 1000 @@ -1877,10 +1894,48 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( resetBatch := func() { batchCount = 0 pargs = pargs[:0] + clear(profilesToInsert) psb.Reset() } executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + profile_uuid, + host_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + COALESCE(command_uuid, '') AS command_uuid, + COALESCE(profile_name, '') AS profile_name + FROM host_mdm_windows_profiles WHERE (profile_uuid, host_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.ProfileUUID, p.HostUUID) + } + var existingProfiles []fleet.MDMWindowsProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.ProfileUUID, exist.HostUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_windows_profiles ( profile_uuid, @@ -1898,11 +1953,25 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( detail = '' `, strings.TrimSuffix(valuePart, ",")) - _, err = tx.ExecContext(ctx, baseStmt, args...) - return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + _, err := tx.ExecContext(ctx, baseStmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + } + updatedDB = true + return nil } for _, p := range profilesToInstall { + profilesToInsert[fmt.Sprintf("%s\n%s", p.ProfileUUID, p.HostUUID)] = &fleet.MDMWindowsProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + Status: nil, + OperationType: fleet.MDMOperationTypeInstall, + Detail: p.Detail, + CommandUUID: p.CommandUUID, + Retries: p.Retries, + } pargs = append( pargs, p.ProfileUUID, p.HostUUID, p.ProfileName, fleet.MDMOperationTypeInstall) @@ -1910,7 +1979,7 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( batchCount++ if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1918,11 +1987,11 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } func (ds *Datastore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 02a4c9d149..a4ee1749c0 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1155,6 +1156,45 @@ func testMDMWindowsInsertCommandForHosts(t *testing.T, ds *Datastore) { cmds, err = ds.MDMWindowsGetPendingCommands(ctx, d2.MDMDeviceID) require.NoError(t, err) require.Len(t, cmds, 2) + + // create a device that enrolls with the same device id and uuid as d1 + // but a different hardware id (simulates the issue in #20764). + d3 := &fleet.MDMWindowsEnrolledDevice{ + MDMDeviceID: d1.MDMDeviceID, + MDMHardwareID: uuid.New().String() + uuid.New().String(), + MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled, + MDMDeviceType: "CIMClient_Windows", + MDMDeviceName: "DESKTOP-1C3ARC1", + MDMEnrollType: "ProgrammaticEnrollment", + MDMEnrollUserID: "", + MDMEnrollProtoVersion: "5.0", + MDMEnrollClientVersion: "10.0.19045.2965", + MDMNotInOOBE: false, + HostUUID: d1.HostUUID, + } + + time.Sleep(time.Second) // ensure it gets a latest created_at + err = ds.MDMWindowsInsertEnrolledDevice(ctx, d3) + require.NoError(t, err) + err = ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, d3.HostUUID, d3.MDMDeviceID) + require.NoError(t, err) + + // commands can still be enqueued, will be enqueued for the latest enrolled device + // when a duplicate host uuid/device id exists (i.e. for d3 even if d2 is passed - + // they have the same ids). + cmd.CommandUUID = uuid.NewString() + err = ds.MDMWindowsInsertCommandForHosts(ctx, []string{d1.MDMDeviceID, d2.MDMDeviceID}, cmd) + require.NoError(t, err) + // command enqueued and created + cmds, err = ds.MDMWindowsGetPendingCommands(ctx, d1.MDMDeviceID) + require.NoError(t, err) + require.Len(t, cmds, 3) + cmds, err = ds.MDMWindowsGetPendingCommands(ctx, d2.MDMDeviceID) + require.NoError(t, err) + require.Len(t, cmds, 3) // d2 sees the new command as we retrieve by device_id and they share the same + cmds, err = ds.MDMWindowsGetPendingCommands(ctx, d3.MDMDeviceID) + require.NoError(t, err) + require.Len(t, cmds, 3) } func testMDMWindowsGetPendingCommands(t *testing.T, ds *Datastore) { @@ -1885,7 +1925,7 @@ func testSetOrReplaceMDMWindowsConfigProfile(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -1983,7 +2023,9 @@ func expectWindowsProfiles( var got []*fleet.MDMWindowsConfigProfile ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + tmID) }) // create map of expected profiles keyed by name @@ -2025,9 +2067,13 @@ func expectWindowsProfiles( func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { ctx := context.Background() - applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile) map[string]string { + applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile, + wantUpdated bool) map[string]string { err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + updatedDB, err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + require.NoError(t, err) + assert.Equal(t, wantUpdated, updatedDB) + return err }) require.NoError(t, err) return expectWindowsProfiles(t, ds, tmID, want) @@ -2041,7 +2087,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -2057,14 +2103,14 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } // apply empty set for no-team - applyAndExpect(nil, nil, nil) + applyAndExpect(nil, nil, nil, false) // apply single profile set for tm1 mTm1 := applyAndExpect([]*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), - }) + }, true) profTm1N1 := getProfileByTeamAndName(ptr.Uint(1), "N1") // apply single profile set for no-team @@ -2072,7 +2118,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "N1", "l1"), }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), - }) + }, true) // wait a second to ensure timestamps in the DB change time.Sleep(time.Second) @@ -2084,7 +2130,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), profTm1N1.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1["I1"], mTm1b["I1"]) profTm1N2 := getProfileByTeamAndName(ptr.Uint(1), "N2") @@ -2102,7 +2148,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { withTeamID(windowsConfigProfileForTest(t, "N1", "l1b"), 1), withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), profTm1N2.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N3", "l3"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1b["I1"], mTm1c["I1"]) // uuid for N2-I2 is unchanged @@ -2119,10 +2165,19 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N4", "l4"), windowsConfigProfileForTest(t, "N5", "l5"), - }) + }, true) + + // apply the same thing again -- nothing updated + applyAndExpect([]*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, nil, []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, false) // clear profiles for tm1 - applyAndExpect(nil, ptr.Uint(1), nil) + applyAndExpect(nil, ptr.Uint(1), nil, true) } // if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise diff --git a/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables.go b/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables.go index 6c4916ab5b..cc8ecaedda 100644 --- a/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables.go +++ b/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables.go @@ -29,13 +29,13 @@ func Up_20220915165115(tx *sql.Tx) error { // Apply MDM Core schema. _, err = tx.Exec(nanoSchema) if err != nil { - return fmt.Errorf("failed to apply nanomdm schema: %w", err) + return fmt.Errorf("failed to apply nanomdm core schema: %w", err) } // Apply MDM DEP schema. _, err = tx.Exec(depSchema) if err != nil { - return fmt.Errorf("failed to apply nanomdm schema: %w", err) + return fmt.Errorf("failed to apply nanomdm dep schema: %w", err) } // Add Fleet domain tables. diff --git a/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables_nanomdm.sql b/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables_nanomdm.sql index 1b058459fb..d446aa5e7d 100644 --- a/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables_nanomdm.sql +++ b/server/datastore/mysql/migrations/tables/20220915165115_AppleMDMTables_nanomdm.sql @@ -79,6 +79,7 @@ CREATE TABLE nano_users ( CHECK (user_authenticate_digest IS NULL OR user_authenticate_digest != '') ); +ALTER TABLE nano_users ADD CONSTRAINT idx_unique_id UNIQUE (id); /* This table represents enrollments which are an amalgamation of * both device and user enrollments. diff --git a/server/datastore/mysql/migrations/tables/20240730374423_AddPlatformToVPPApps.go b/server/datastore/mysql/migrations/tables/20240730374423_AddPlatformToVPPApps.go index cff1b47efe..71bb7fa129 100644 --- a/server/datastore/mysql/migrations/tables/20240730374423_AddPlatformToVPPApps.go +++ b/server/datastore/mysql/migrations/tables/20240730374423_AddPlatformToVPPApps.go @@ -28,6 +28,19 @@ func Up_20240730374423(tx *sql.Tx) error { return fmt.Errorf("updating platform in vpp_apps: %w", err) } + // Drop foreign keys first so they don't interfere with updating primary key. + _, err = tx.Exec(`ALTER TABLE vpp_apps_teams DROP FOREIGN KEY vpp_apps_teams_ibfk_1`) + if err != nil { + return fmt.Errorf("updating foreign key in vpp_apps: %w", err) + } + + // We drop this foreign key in this migration (for MySQL 8.4). It will be added back in the next migration. + _, err = tx.Exec(` + ALTER TABLE host_vpp_software_installs DROP FOREIGN KEY host_vpp_software_installs_ibfk_2`) + if err != nil { + return fmt.Errorf("drop foreign key in host_vpp_software_installs: %w", err) + } + _, err = tx.Exec(`ALTER TABLE vpp_apps DROP PRIMARY KEY, ADD PRIMARY KEY (adam_id, platform)`) if err != nil { return fmt.Errorf("updating primary key in vpp_apps: %w", err) @@ -57,7 +70,7 @@ func Up_20240730374423(tx *sql.Tx) error { if err != nil { return fmt.Errorf("updating key in vpp_apps: %w", err) } - _, err = tx.Exec(`ALTER TABLE vpp_apps_teams DROP FOREIGN KEY vpp_apps_teams_ibfk_1, ADD FOREIGN KEY vpp_apps_teams_ibfk_1 (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`) + _, err = tx.Exec(`ALTER TABLE vpp_apps_teams ADD FOREIGN KEY vpp_apps_teams_ibfk_3 (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`) if err != nil { return fmt.Errorf("updating foreign key in vpp_apps: %w", err) } diff --git a/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go index 6b70578339..aadd49fe80 100644 --- a/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go +++ b/server/datastore/mysql/migrations/tables/20240801115359_AddPlatformToHostVPPSoftwareInstalls.go @@ -45,9 +45,16 @@ func Up_20240801115359(tx *sql.Tx) error { if err != nil { return fmt.Errorf("updating key in host_vpp_software_installs: %w", err) } + if indexExistsTx(tx, "host_vpp_software_installs", "host_vpp_software_installs_ibfk_2") { + _, err = tx.Exec(` + ALTER TABLE host_vpp_software_installs DROP FOREIGN KEY host_vpp_software_installs_ibfk_2`) + if err != nil { + return fmt.Errorf("updating foreign key in host_vpp_software_installs: %w", err) + } + } _, err = tx.Exec(` - ALTER TABLE host_vpp_software_installs DROP FOREIGN KEY host_vpp_software_installs_ibfk_2, - ADD FOREIGN KEY host_vpp_software_installs_ibfk_3 (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`) + ALTER TABLE host_vpp_software_installs + ADD CONSTRAINT host_vpp_software_installs_ibfk_3 FOREIGN KEY (adam_id, platform) REFERENCES vpp_apps (adam_id, platform) ON DELETE CASCADE`) if err != nil { return fmt.Errorf("updating foreign key in host_vpp_software_installs: %w", err) } diff --git a/server/datastore/mysql/migrations/tables/20240729120947_AddSoftwareInstallResultDeletedAt.go b/server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go similarity index 75% rename from server/datastore/mysql/migrations/tables/20240729120947_AddSoftwareInstallResultDeletedAt.go rename to server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go index bd4219364d..b5f7e79213 100644 --- a/server/datastore/mysql/migrations/tables/20240729120947_AddSoftwareInstallResultDeletedAt.go +++ b/server/datastore/mysql/migrations/tables/20240802101043_AddSoftwareInstallResultDeletedAt.go @@ -6,10 +6,11 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20240729120947, Down_20240729120947) + MigrationClient.AddMigration(Up_20240802101043, Down_20240802101043) } -func Up_20240729120947(tx *sql.Tx) error { +// This is a new copy of a previous out-of-order migration +func Up_20240802101043(tx *sql.Tx) error { _, err := tx.Exec("ALTER TABLE host_software_installs ADD COLUMN host_deleted_at timestamp NULL DEFAULT NULL") if err != nil { return fmt.Errorf("failed to create host_deleted_at column on host_software_installs table: %w", err) @@ -33,6 +34,6 @@ AND return nil } -func Down_20240729120947(tx *sql.Tx) error { +func Down_20240802101043(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go new file mode 100644 index 0000000000..c91e99099e --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig.go @@ -0,0 +1,67 @@ +package tables + +import ( + "database/sql" + "encoding/json" + "fmt" + "reflect" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" +) + +func init() { + MigrationClient.AddMigration(Up_20240802113716, Down_20240802113716) +} + +func Up_20240802113716(tx *sql.Tx) error { + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + + type row struct { + Config json.RawMessage `db:"config"` + ID uint `db:"id"` + } + + var rows []row + if err := txx.Select(&rows, "SELECT config, id FROM teams"); err != nil { + return fmt.Errorf("selecting team configs: %w", err) + } + + for _, r := range rows { + + config := make(map[string]any) + if err := json.Unmarshal(r.Config, &config); err != nil { + return fmt.Errorf("unmarshal team config: %w", err) + } + softwareData, ok := config["software"] + if !ok { + continue + } + + rt := reflect.TypeOf(config["software"]) + if rt == nil { + continue + } + + if rt.Kind() == reflect.Slice { + // then we have an older config without the new fields + // Note: we are setting the new key to be whatever the old key was (if it was null, then + // it's set to null, if it was empty array, then it's set to empty array) + config["software"] = map[string]any{"packages": softwareData} + b, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("marshal updated team config: %w", err) + } + if _, err := tx.Exec(`UPDATE teams SET config = ? WHERE id = ?`, b, r.ID); err != nil { + return fmt.Errorf("updating config for team %d: %w", r.ID, err) + } + } + + } + + return nil +} + +func Down_20240802113716(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go new file mode 100644 index 0000000000..0bd2edcab4 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240802113716_UpdateSoftwareGitopsConfig_test.go @@ -0,0 +1,268 @@ +package tables + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestUp_20240802113716(t *testing.T) { + db := applyUpToPrev(t) + + badCfg := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "software": [ + { + "url": "http://localhost:8100/1Password.pkg", + "self_service": true, + "install_script": { + "path": "" + }, + "pre_install_query": { + "path": "" + }, + "post_install_script": { + "path": "" + } + } + ], + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_webhook": false + }, + "failing_policies_webhook": { + "policy_ids": null, + "destination_url": "", + "host_batch_size": 0, + "enable_failing_policies_webhook": false + } + }, + "host_expiry_settings": { + "host_expiry_window": 30, + "host_expiry_enabled": true + } +} + +` + + badCfgEmptyArr := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "software": [], + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_webhook": false + }, + "failing_policies_webhook": { + "policy_ids": null, + "destination_url": "", + "host_batch_size": 0, + "enable_failing_policies_webhook": false + } + }, + "host_expiry_settings": { + "host_expiry_window": 30, + "host_expiry_enabled": true + } +} +` + + badCfgNoSoftwareField := ` +{ + "mdm": { + "ios_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_setup": { + "bootstrap_package": "", + "macos_setup_assistant": "", + "enable_end_user_authentication": false, + "enable_release_device_manually": false + }, + "macos_updates": { + "deadline": "", + "minimum_version": "" + }, + "ipados_updates": { + "deadline": "", + "minimum_version": "" + }, + "macos_settings": { + "custom_settings": [] + }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, + "windows_settings": { + "custom_settings": [] + }, + "enable_disk_encryption": false + }, + "scripts": [], + "features": { + "enable_host_users": true, + "enable_software_inventory": true + }, + "integrations": { + "jira": null, + "zendesk": null, + "google_calendar": { + "webhook_url": "", + "enable_calendar_events": false + } + }, + "webhook_settings": { + "host_status_webhook": { + "days_count": 0, + "destination_url": "", + "host_percentage": 0, + "enable_host_status_webhook": false + }, + "failing_policies_webhook": { + "policy_ids": null, + "destination_url": "", + "host_batch_size": 0, + "enable_failing_policies_webhook": false + } + }, + "host_expiry_settings": { + "host_expiry_window": 30, + "host_expiry_enabled": true + } +} +` + + tid1 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 1", badCfg) + tid2 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 2", badCfgEmptyArr) + tid3 := execNoErrLastID(t, db, `INSERT INTO teams (name, config) VALUES (?,?)`, "team 3", badCfgNoSoftwareField) + + // Apply current migration. + applyNext(t, db) + + var team fleet.Team + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid1)) + + // Team with a package should see it in the new field + require.NotNil(t, team.Config.Software) + require.True(t, team.Config.Software.Packages.Set) + require.True(t, team.Config.Software.Packages.Valid) + require.Len(t, team.Config.Software.Packages.Value, 1) + + require.False(t, team.Config.Software.AppStoreApps.Set) + require.False(t, team.Config.Software.AppStoreApps.Valid) + require.Len(t, team.Config.Software.AppStoreApps.Value, 0) + + team = fleet.Team{} + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid2)) + + // Team with an empty array originally should have JSON null set for packages + require.NotNil(t, team.Config.Software) + require.True(t, team.Config.Software.Packages.Set) + require.True(t, team.Config.Software.Packages.Valid) + require.Len(t, team.Config.Software.Packages.Value, 0) + + require.False(t, team.Config.Software.AppStoreApps.Set) + require.False(t, team.Config.Software.AppStoreApps.Valid) + require.Len(t, team.Config.Software.AppStoreApps.Value, 0) + + team = fleet.Team{} + require.NoError(t, db.Get(&team, "SELECT id, config FROM teams WHERE id = ?", tid3)) + + require.Nil(t, team.Config.Software) +} diff --git a/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults.go b/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults.go new file mode 100644 index 0000000000..6a21013806 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults.go @@ -0,0 +1,22 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240814135330, Down_20240814135330) +} + +func Up_20240814135330(tx *sql.Tx) error { + // This index optimizes finding the most recent query result for a given query and host + if _, err := tx.Exec(`ALTER TABLE query_results ADD INDEX idx_query_id_host_id_last_fetched (query_id, host_id, last_fetched)`); err != nil { + return fmt.Errorf("creating query_results index: %w", err) + } + return nil +} + +func Down_20240814135330(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults_test.go b/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults_test.go new file mode 100644 index 0000000000..74afe0c49b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240814135330_AddIndexToQueryResults_test.go @@ -0,0 +1,27 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240814135330(t *testing.T) { + db := applyUpToPrev(t) + + // Apply current migration + applyNext(t, db) + + // Check if the index exists + var indexExists bool + err := db.QueryRow(` + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'query_results' + AND index_name = 'idx_query_id_host_id_last_fetched' + `).Scan(&indexExists) + + require.NoError(t, err) + require.True(t, indexExists, "Index idx_query_id_host_id_last_fetched should exist") + +} diff --git a/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex.go b/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex.go new file mode 100644 index 0000000000..5af70f0154 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex.go @@ -0,0 +1,21 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240815000000, Down_20240815000000) +} + +func Up_20240815000000(tx *sql.Tx) error { + if _, err := tx.Exec(`CREATE INDEX idx_jobs_state_not_before_updated_at ON jobs (state, not_before, updated_at);`); err != nil { + return fmt.Errorf("creating jobs index: %w", err) + } + return nil +} + +func Down_20240815000000(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex_test.go b/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex_test.go new file mode 100644 index 0000000000..bc923ad3a6 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240815000000_AddJobsIndex_test.go @@ -0,0 +1,27 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240815000000(t *testing.T) { + db := applyUpToPrev(t) + + // Apply current migration + applyNext(t, db) + + // Check if the index exists + var indexExists bool + err := db.QueryRow(` + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'jobs' + AND index_name = 'idx_jobs_state_not_before_updated_at' + `).Scan(&indexExists) + + require.NoError(t, err) + require.True(t, indexExists, "Index idx_jobs_state_not_before_updated_at should exist") + +} diff --git a/server/datastore/mysql/migrations/tables/20240815000001_AddSelfServiceToVPPAppsTeams.go b/server/datastore/mysql/migrations/tables/20240815000001_AddSelfServiceToVPPAppsTeams.go new file mode 100644 index 0000000000..83333db387 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240815000001_AddSelfServiceToVPPAppsTeams.go @@ -0,0 +1,21 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240815000001, Down_20240815000001) +} + +func Up_20240815000001(tx *sql.Tx) error { + if _, err := tx.Exec("ALTER TABLE vpp_apps_teams ADD COLUMN self_service bool NOT NULL DEFAULT false"); err != nil { + return fmt.Errorf("Failed to add self_service to vpp_apps_teams: %w", err) + } + return nil +} + +func Down_20240815000001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers.go b/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers.go new file mode 100644 index 0000000000..28ffb331ec --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers.go @@ -0,0 +1,27 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240816103247, Down_20240816103247) +} + +func Up_20240816103247(tx *sql.Tx) error { + + // This constraint is required for MySQL 8.4.2 because nano_enrollments foreign key expects nano_users.id to be unique. + if !indexExistsTx(tx, "nano_users", "idx_unique_id") { + _, err := tx.Exec(`ALTER TABLE nano_users ADD CONSTRAINT idx_unique_id UNIQUE (id)`) + if err != nil { + return fmt.Errorf("adding unique index to nano_users: %w", err) + } + } + + return nil +} + +func Down_20240816103247(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers_test.go b/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers_test.go new file mode 100644 index 0000000000..f63bcf2995 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240816103247_AddIndexToNanoUsers_test.go @@ -0,0 +1,26 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240816103247(t *testing.T) { + db := applyUpToPrev(t) + + // Apply current migration + applyNext(t, db) + + // Check if the index exists + var indexExists bool + err := db.QueryRow(` + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'nano_users' + AND index_name = 'idx_unique_id' + `).Scan(&indexExists) + + require.NoError(t, err) + require.True(t, indexExists, "Index idx_unique_id should exist") +} diff --git a/server/datastore/mysql/migrations/tables/20240820091218_AddHostMDMCommands.go b/server/datastore/mysql/migrations/tables/20240820091218_AddHostMDMCommands.go new file mode 100644 index 0000000000..5ab9a7e791 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240820091218_AddHostMDMCommands.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240820091218, Down_20240820091218) +} + +func Up_20240820091218(tx *sql.Tx) error { + if tableExists(tx, "host_mdm_commands") { + return nil + } + + _, err := tx.Exec(` +-- This table is used to track the MDM commands that have been sent to a host. +-- For example, if 'refetch apps' command was already sent to a host, we don't want +-- to send it again. +CREATE TABLE host_mdm_commands ( + host_id int unsigned NOT NULL, + command_type VARCHAR(31) COLLATE utf8mb4_unicode_ci NOT NULL, + created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6), + updated_at TIMESTAMP(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (host_id, command_type) +)`) + if err != nil { + return fmt.Errorf("failed to create table host_mdm_commands: %w", err) + } + + return nil +} + +func Down_20240820091218(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240826111228_AdjustHostIndexes.go b/server/datastore/mysql/migrations/tables/20240826111228_AdjustHostIndexes.go new file mode 100644 index 0000000000..e6ceeae9d1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240826111228_AdjustHostIndexes.go @@ -0,0 +1,41 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240826111228, Down_20240826111228) +} + +func Up_20240826111228(tx *sql.Tx) error { + _, err := tx.Exec(` + ALTER TABLE hosts + DROP INDEX host_ip_mac_search + `) + if err != nil { + return fmt.Errorf("dropping host_ip_mac_search index: %w", err) + } + + _, err = tx.Exec(` + ALTER TABLE hosts + DROP INDEX hosts_search + `) + if err != nil { + return fmt.Errorf("dropping hosts_search index: %w", err) + } + + _, err = tx.Exec(` + ALTER TABLE hosts + ADD INDEX idx_hosts_uuid (uuid); + `) + if err != nil { + return fmt.Errorf("adding hosts_uuid index: %w", err) + } + return nil +} + +func Down_20240826111228(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls.go b/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls.go new file mode 100644 index 0000000000..6a99216cac --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls.go @@ -0,0 +1,103 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" +) + +func init() { + MigrationClient.AddMigration(Up_20240826160025, Down_20240826160025) +} + +func Up_20240826160025(tx *sql.Tx) error { + if !columnExists(tx, "host_software_installs", "removed") { + if _, err := tx.Exec("ALTER TABLE host_software_installs ADD COLUMN removed TINYINT NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("failed to add removed to host_software_installs: %w", err) + } + } + + if !columnExists(tx, "host_vpp_software_installs", "removed") { + if _, err := tx.Exec("ALTER TABLE host_vpp_software_installs ADD COLUMN removed TINYINT NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("failed to add removed to host_vpp_software_installs: %w", err) + } + } + + // Mark software installs as removed if the software is no longer installed on the host. + // Note that some software never shows up in software table after being installed (because software detail query can't find it). + // So, we will only mark software as removed if it shows up in the software table (for another host). + getPackagesRemovedStmt := ` + SELECT DISTINCT hsi.id + FROM host_software_installs hsi + INNER JOIN software_installers si ON hsi.software_installer_id = si.id + INNER JOIN software_titles st ON si.title_id = st.id + -- software is installed on some host + INNER JOIN software s ON s.title_id = st.id + INNER JOIN hosts h ON hsi.host_id = h.id + WHERE NOT EXISTS ( + -- software is not installed on this specific host + SELECT 1 FROM host_software hs + INNER JOIN software s2 ON hs.software_id = s2.id AND s2.title_id = st.id + WHERE hs.host_id = hsi.host_id + ) AND ( + -- software status is: Installed + (hsi.post_install_script_exit_code IS NOT NULL AND hsi.post_install_script_exit_code = 0) OR + (hsi.post_install_script_exit_code IS NULL AND hsi.install_script_exit_code IS NOT NULL AND hsi.install_script_exit_code = 0) + ) AND + -- software was refetched after it was installed + hsi.updated_at < h.detail_updated_at +` + var ids []uint + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + if err := txx.Select(&ids, getPackagesRemovedStmt); err != nil { + return fmt.Errorf("failed to find host_software_installs to remove: %w", err) + } + if len(ids) > 0 { + stmt, args, err := sqlx.In("UPDATE host_software_installs SET removed = 1 WHERE id IN (?)", ids) + if err != nil { + return fmt.Errorf("failed to expand slice value for host_software_installs: %w", err) + } + if _, err := tx.Exec(stmt, args...); err != nil { + return fmt.Errorf("failed to mark host_software_installs as removed: %w", err) + } + } + + // Mark VPP installs as removed if the software is no longer installed on the host. + getVppRemovedStmt := ` + SELECT DISTINCT hvsi.id + FROM host_vpp_software_installs hvsi + INNER JOIN vpp_apps vap ON hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform + INNER JOIN software_titles st ON vap.title_id = st.id + INNER JOIN hosts h ON hvsi.host_id = h.id + WHERE NOT EXISTS ( + -- software is not installed on this specific host + SELECT 1 FROM host_software hs + INNER JOIN software s2 ON hs.software_id = s2.id AND s2.title_id = st.id + WHERE hs.host_id = hvsi.host_id + ) AND + -- software was refetched after it was installed + hvsi.updated_at < h.detail_updated_at +` + + var vppIDs []uint + if err := txx.Select(&vppIDs, getVppRemovedStmt); err != nil { + return fmt.Errorf("failed to find host_vpp_software_installs to remove: %w", err) + } + if len(vppIDs) > 0 { + stmt, args, err := sqlx.In("UPDATE host_vpp_software_installs SET removed = 1 WHERE id IN (?)", vppIDs) + if err != nil { + return fmt.Errorf("failed to expand slice value for host_vpp_software_installs: %w", err) + } + if _, err := tx.Exec(stmt, args...); err != nil { + return fmt.Errorf("failed to mark host_vpp_software_installs as removed: %w", err) + } + } + + return nil +} + +func Down_20240826160025(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls_test.go b/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls_test.go new file mode 100644 index 0000000000..1077183917 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240826160025_AddRemovedToInstalls_test.go @@ -0,0 +1,142 @@ +package tables + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240826160025(t *testing.T) { + db := applyUpToPrev(t) + + // Create user + u1 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u1", "u1@b.c", "1234", "salt") + // Create host + insertHostStmt := ` + INSERT INTO hosts ( + hostname, uuid, platform, osquery_version, os_version, build, platform_like, code_name, + cpu_type, cpu_subtype, cpu_brand, hardware_vendor, hardware_model, hardware_version, + hardware_serial, computer_name, team_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + hostName := "Dummy Hostname" + hostUUID := "12345678-1234-1234-1234-123456789012" + hostPlatform := "ios" + osqueryVer := "5.9.1" + osVersion := "Windows 10" + buildVersion := "10.0.19042.1234" + platformLike := "apple" + codeName := "20H2" + cpuType := "x86_64" + cpuSubtype := "x86_64" + cpuBrand := "Intel" + hwVendor := "Dell Inc." + hwModel := "OptiPlex 7090" + hwVersion := "1.0" + hwSerial := "ABCDEFGHIJ" + computerName := "DESKTOP-TEST" + + hostID1 := execNoErrLastID(t, db, insertHostStmt, hostName, hostUUID, hostPlatform, osqueryVer, + osVersion, buildVersion, platformLike, codeName, cpuType, cpuSubtype, cpuBrand, hwVendor, hwModel, hwVersion, hwSerial, + computerName, nil) + hostID2 := execNoErrLastID(t, db, insertHostStmt, hostName, hostUUID, hostPlatform, osqueryVer, + osVersion, buildVersion, platformLike, codeName, cpuType, cpuSubtype, cpuBrand, hwVendor, hwModel, hwVersion, hwSerial, + computerName, nil) + + // Insert data into software_titles + title1 := execNoErrLastID(t, db, "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", "sw1", "src1", "") + title2 := execNoErrLastID(t, db, "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", "sw2", "src2", "") + title3 := execNoErrLastID(t, db, "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", "sw3", "src3", "") + title4 := execNoErrLastID(t, db, "INSERT INTO software_titles (name, source, browser) VALUES (?, ?, ?)", "sw4", "src4", "") + + // Insert software + const insertStmt = `INSERT INTO software + (name, version, source, browser, checksum, title_id) + VALUES + (?, ?, ?, ?, ?, ?)` + execNoErr(t, db, insertStmt, "sw1", "1.0", "src1", "", "1", title1) + sw2 := execNoErrLastID(t, db, insertStmt, "sw2", "2.0", "src2", "", "2", title2) + sw3 := execNoErrLastID(t, db, insertStmt, "sw3", "3.0", "src3", "", "3", title3) + // sw4 is not in software table + + // Insert host_software + execNoErr(t, db, "INSERT INTO host_software (host_id, software_id) VALUES (?, ?)", hostID2, sw2) + execNoErr(t, db, "INSERT INTO host_software (host_id, software_id) VALUES (?, ?)", hostID2, sw3) + + // Create package apps + // 1 app will remain because it is not in the software table (sw1) + // 1 app will remain because it is still installed + // 1 app will be removed on host 1 but remain on host 2 + execNoErr(t, db, `INSERT INTO script_contents (id, md5_checksum, contents) VALUES (1, 'checksum', 'script content')`) + siStmt := `INSERT INTO software_installers + (title_id, filename, version, platform, install_script_content_id, storage_id) + VALUES + (?,?,?,?,?,?)` + si1 := execNoErrLastID(t, db, siStmt, title1, "sw1-installer.pkg", "1.2", hostPlatform, 1, "storage-id1") + si2 := execNoErrLastID(t, db, siStmt, title2, "sw2-installer.pkg", "2.2", hostPlatform, 1, "storage-id2") + si3 := execNoErrLastID(t, db, siStmt, title3, "sw3-installer.pkg", "3.2", hostPlatform, 1, "storage-id3") + si4 := execNoErrLastID(t, db, siStmt, title4, "sw3-installer.pkg", "4.2", hostPlatform, 1, "storage-id4") + + hsiStmt := ` + INSERT INTO host_software_installs ( + host_id, + execution_id, + software_installer_id, + install_script_exit_code, + post_install_script_exit_code + ) VALUES (?, ?, ?, ?, ?)` + hsi1 := execNoErrLastID(t, db, hsiStmt, hostID1, "execution-id1", si1, 0, nil) // will be removed + hsi2_1 := execNoErrLastID(t, db, hsiStmt, hostID2, "execution-id2_1", si2, nil, nil) // remains because it is still being installed + hsi2_2 := execNoErrLastID(t, db, hsiStmt, hostID2, "execution-id2_2", si2, 0, nil) + hsi3_1 := execNoErrLastID(t, db, hsiStmt, hostID1, "execution-id3_1", si3, 0, 0) // will be removed because it is not in host_software + hsi3_2 := execNoErrLastID(t, db, hsiStmt, hostID2, "execution-id3_2", si3, 0, 0) + hsi4 := execNoErrLastID(t, db, hsiStmt, hostID1, "execution-id4", si4, 0, 0) // remains because it is not in software table + + // Create VPP apps -- 1 VPP app will be removed, 1 will remain + adamID1 := "removed" + execNoErr( + t, db, `INSERT INTO vpp_apps (adam_id, platform, title_id) VALUES (?,?,?)`, adamID1, hostPlatform, title1, + ) + adamID2 := "kept" + execNoErr( + t, db, `INSERT INTO vpp_apps (adam_id, platform, title_id) VALUES (?,?,?)`, adamID2, hostPlatform, title2, + ) + + // create VPP installs + hvsi1 := execNoErrLastID(t, db, + `INSERT INTO host_vpp_software_installs (host_id, adam_id, platform, command_uuid, user_id) VALUES (?,?,?,?,?)`, + hostID1, adamID1, hostPlatform, "command_uuid", u1) + hvsi2 := execNoErrLastID(t, db, + `INSERT INTO host_vpp_software_installs (host_id, adam_id, platform, command_uuid, user_id) VALUES (?,?,?,?,?)`, + hostID2, adamID2, hostPlatform, "command_uuid2", u1) + + time.Sleep(1 * time.Second) // because we are not using max timestamp precision + execNoErr(t, db, `UPDATE hosts SET detail_updated_at = NOW()`) + + // Apply current migration. + applyNext(t, db) + var removed bool + + // Check packages + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi1)) + assert.True(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi2_1)) + assert.False(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi2_2)) + assert.False(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi3_1)) + assert.True(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi3_2)) + assert.False(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_software_installs WHERE id = ?`, hsi4)) + assert.False(t, removed) + + // Check VPP + require.NoError(t, db.Get(&removed, `SELECT removed from host_vpp_software_installs WHERE id = ?`, hvsi1)) + assert.True(t, removed) + require.NoError(t, db.Get(&removed, `SELECT removed from host_vpp_software_installs WHERE id = ?`, hvsi2)) + assert.False(t, removed) + +} diff --git a/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens.go b/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens.go new file mode 100644 index 0000000000..f7b010210b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens.go @@ -0,0 +1,196 @@ +package tables + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240829165448, Down_20240829165448) +} + +func Up_20240829165448(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE abm_tokens ( + id int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + organization_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + apple_id varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + terms_expired tinyint(1) NOT NULL DEFAULT '0', + renew_at timestamp NOT NULL, + -- encrypted token, encrypted with the ABM cert and key and encrypted again + -- with the FleetConfig.Server.PrivateKey + token blob NOT NULL, + + -- those team_id fields are the default teams where devices from this ABM + -- will be enrolled during the DEP process, based on the device's platform + -- (NULL means "no team"). + macos_default_team_id int(10) UNSIGNED DEFAULT NULL, + ios_default_team_id int(10) UNSIGNED DEFAULT NULL, + ipados_default_team_id int(10) UNSIGNED DEFAULT NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + + UNIQUE KEY idx_abm_tokens_organization_name (organization_name), + + CONSTRAINT fk_abm_tokens_macos_default_team_id + FOREIGN KEY (macos_default_team_id) REFERENCES teams (id) ON DELETE SET NULL, + CONSTRAINT fk_abm_tokens_ios_default_team_id + FOREIGN KEY (ios_default_team_id) REFERENCES teams (id) ON DELETE SET NULL, + CONSTRAINT fk_abm_tokens_ipados_default_team_id + FOREIGN KEY (ipados_default_team_id) REFERENCES teams (id) ON DELETE SET NULL +)`) + if err != nil { + return fmt.Errorf("failed to create table abm_tokens: %w", err) + } + + _, err = tx.Exec(` +ALTER TABLE host_dep_assignments + ADD COLUMN abm_token_id int(10) UNSIGNED NULL, + ADD CONSTRAINT fk_host_dep_assignments_abm_token_id + FOREIGN KEY (abm_token_id) REFERENCES abm_tokens(id) ON DELETE SET NULL +`) + if err != nil { + return fmt.Errorf("failed to alter table host_dep_assignments: %w", err) + } + + // migrate the existing ABM token (if any) to the new abm_tokens table + const getABM = ` +SELECT + value +FROM + mdm_config_assets +WHERE + name = ? AND deletion_uuid = '' +` + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + var token []byte + if err := txx.Get(&token, getABM, fleet.MDMAssetABMTokenDeprecated); err != nil { + if errors.Is(err, sql.ErrNoRows) { + // nothing to migrate, exit early + return nil + } + return fmt.Errorf("selecting existing ABM token: %w", err) + } + + // get the current ABM configuration from the app config + const getABMCfg = ` +SELECT + json_value->"$.mdm" +FROM + app_config_json +LIMIT 1 +` + var raw sql.Null[json.RawMessage] + if err := txx.Get(&raw, getABMCfg); err != nil { + return fmt.Errorf("select MDM config from app_config_json: %w", err) + } + + var ( + abmTermsExpired bool + abmDefaultTeam string + ) + // decode if we did get an object + if raw.Valid && len(raw.V) > 0 && raw.V[0] == '{' { + var config map[string]interface{} + if err := json.Unmarshal(raw.V, &config); err != nil { + return fmt.Errorf("unmarshal appconfig: %w", err) + } + + if s, ok := config["apple_bm_default_team"].(string); ok { + abmDefaultTeam = s + } + if b, ok := config["apple_bm_terms_expired"].(bool); ok { + abmTermsExpired = b + } + } + + var defaultTeamID *uint + if abmDefaultTeam != "" { + // get the default team id + var id uint + if err := txx.Get(&id, "SELECT id FROM teams WHERE name = ?", abmDefaultTeam); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("select default ABM team id: %w", err) + } + } + // only use it if the team exists + if id > 0 { + defaultTeamID = &id + } + } + + // NOTE: we don't know the organization_name, apple_id and renew_at of the + // existing token - the only way to know is to make an Apple API call and to + // decrypt the token, two operations that are not safe to do in a DB + // migration. Instead, insert with empty values and we have a check in the + // cron job ("apple_mdm_dep_profile_assigner") that runs regularly (and + // ~immediately at Fleet startup) to ensure the token's information is + // filled. + // https://github.com/fleetdm/fleet/pull/21287#discussion_r1715891448 + + // insert the token in the new table + const insABM = ` +INSERT INTO abm_tokens + ( + organization_name, + apple_id, + terms_expired, + renew_at, + token, + macos_default_team_id, + ios_default_team_id, + ipados_default_team_id + ) +VALUES + ('', '', ?, DATE('2000-01-01'), ?, ?, ?, ?) +` + res, err := tx.Exec(insABM, abmTermsExpired, token, defaultTeamID, defaultTeamID, defaultTeamID) + if err != nil { + return fmt.Errorf("insert existing ABM token into abm_tokens: %w", err) + } + tokenID, _ := res.LastInsertId() + + // soft-delete the token from the deprecated storage + const delABM = ` +UPDATE + mdm_config_assets +SET + deleted_at = CURRENT_TIMESTAMP(), + deletion_uuid = ? +WHERE + name = ? AND deletion_uuid = '' +` + deletionUUID := uuid.New().String() + if _, err = tx.Exec(delABM, deletionUUID, fleet.MDMAssetABMTokenDeprecated); err != nil { + return fmt.Errorf("delete ABM token from mdm_config_assets: %w", err) + } + + // associate all existing host DEP enrollments with the existing token + const updDEP = ` +UPDATE + host_dep_assignments +SET + abm_token_id = ? +WHERE + deleted_at IS NULL +` + if _, err = tx.Exec(updDEP, tokenID); err != nil { + return fmt.Errorf("update ABM token link in host_dep_assignments: %w", err) + } + + return nil +} + +func Down_20240829165448(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens_test.go b/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens_test.go new file mode 100644 index 0000000000..07804074fd --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165448_SupportMultipleABMTokens_test.go @@ -0,0 +1,272 @@ +package tables + +import ( + "crypto/md5" //nolint:gosec + "database/sql" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUp_20240829165448(t *testing.T) { + createTokenAndHash := func() (string, []byte) { + tok := uuid.NewString() + h := md5.New() //nolint:gosec + _, _ = h.Write([]byte(tok)) + md5Checksum := h.Sum(nil) + return tok, md5Checksum + } + + type abmToken struct { + ID uint `db:"id"` + OrganizationName string `db:"organization_name"` + AppleID string `db:"apple_id"` + TermsExpired bool `db:"terms_expired"` + RenewAt time.Time `db:"renew_at"` + Token []byte `db:"token"` + MacOSDefaultTeamID *uint `db:"macos_default_team_id"` + IOSDefaultTeamID *uint `db:"ios_default_team_id"` + IPadOSDefaultTeamID *uint `db:"ipados_default_team_id"` + } + + t.Run("NoExistingToken", func(t *testing.T) { + db := applyUpToPrev(t) + + // create a host with a DEP assignment (should not exist when there is no + // ABM, but maybe ABM was setup then removed) + hostID := insertHost(t, db, nil) + execNoErr(t, db, `INSERT INTO host_dep_assignments (host_id) VALUES (?)`, hostID) + + // Apply current migration. + applyNext(t, db) + + var exists int + // no ABM token in the old storage + err := db.Get(&exists, `SELECT 1 FROM mdm_config_assets WHERE name = 'abm_token'`) + require.ErrorIs(t, err, sql.ErrNoRows) + // no ABM token in the new storage + err = db.Get(&exists, `SELECT 1 FROM abm_tokens`) + require.ErrorIs(t, err, sql.ErrNoRows) + + // the existing host DEP assignment is still not linked to any token + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT abm_token_id FROM host_dep_assignments WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.Nil(t, hostTokenID) + }) + + t.Run("ExistingTokenWithTeamTermsFalse", func(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing ABM token + existingToken, md5Checksum := createTokenAndHash() + execNoErr(t, db, `INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('abm_token', ?, ?)`, existingToken, md5Checksum) + + // set a config for ABM + execNoErr(t, db, `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm', JSON_OBJECT('apple_bm_default_team', 'team1'))`) + + // create the corresponding team + tmID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES (?)`, "team1") + + // create a host with a DEP assignment + hostID := insertHost(t, db, ptr.Uint(uint(tmID))) + execNoErr(t, db, `INSERT INTO host_dep_assignments (host_id) VALUES (?)`, hostID) + + // Apply current migration. + applyNext(t, db) + + // ABM token is now soft-deleted in the old storage + var assetDeletedUUID string + err := db.Get(&assetDeletedUUID, `SELECT deletion_uuid FROM mdm_config_assets WHERE name = 'abm_token'`) + require.NoError(t, err) + require.NotEmpty(t, assetDeletedUUID) + + // ABM token is stored in the new storage, with the expected config + var storedToken abmToken + err = db.Get(&storedToken, ` +SELECT + id, organization_name, apple_id, terms_expired, renew_at, token, macos_default_team_id, ios_default_team_id, ipados_default_team_id +FROM + abm_tokens +LIMIT 1`) + require.NoError(t, err) + + // we don't have those fields during DB migration + require.Empty(t, storedToken.OrganizationName) + require.Empty(t, storedToken.AppleID) + require.Equal(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), storedToken.RenewAt) + // terms were not set as expired in appconfig + require.False(t, storedToken.TermsExpired) + // token matches + require.Equal(t, existingToken, string(storedToken.Token)) + // all platform default teams are set to the configured team + require.NotNil(t, storedToken.MacOSDefaultTeamID) + require.EqualValues(t, tmID, *storedToken.MacOSDefaultTeamID) + require.NotNil(t, storedToken.IOSDefaultTeamID) + require.EqualValues(t, tmID, *storedToken.IOSDefaultTeamID) + require.NotNil(t, storedToken.IPadOSDefaultTeamID) + require.EqualValues(t, tmID, *storedToken.IPadOSDefaultTeamID) + + // the existing host DEP assignment is linked to the token + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT abm_token_id FROM host_dep_assignments WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.NotNil(t, hostTokenID) + require.EqualValues(t, storedToken.ID, *hostTokenID) + }) + + t.Run("ExistingTokenWithInvalidTeamTermsTrue", func(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing ABM token + existingToken, md5Checksum := createTokenAndHash() + execNoErr(t, db, `INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('abm_token', ?, ?)`, existingToken, md5Checksum) + + // set a config for ABM + execNoErr(t, db, `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm', JSON_OBJECT('apple_bm_default_team', 'no-such-team', 'apple_bm_terms_expired', true))`) + + // create a team, but not one matching the default team name + tmID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES (?)`, "team1") + + // create a host with a DEP assignment + hostID := insertHost(t, db, ptr.Uint(uint(tmID))) + execNoErr(t, db, `INSERT INTO host_dep_assignments (host_id) VALUES (?)`, hostID) + + // Apply current migration. + applyNext(t, db) + + // ABM token is now soft-deleted in the old storage + var assetDeletedUUID string + err := db.Get(&assetDeletedUUID, `SELECT deletion_uuid FROM mdm_config_assets WHERE name = 'abm_token'`) + require.NoError(t, err) + require.NotEmpty(t, assetDeletedUUID) + + // ABM token is stored in the new storage, with the expected config + var storedToken abmToken + err = db.Get(&storedToken, ` +SELECT + id, organization_name, apple_id, terms_expired, renew_at, token, macos_default_team_id, ios_default_team_id, ipados_default_team_id +FROM + abm_tokens +LIMIT 1`) + require.NoError(t, err) + + // we don't have those fields during DB migration + require.Empty(t, storedToken.OrganizationName) + require.Empty(t, storedToken.AppleID) + require.Equal(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), storedToken.RenewAt) + // terms were set as expired in appconfig + require.True(t, storedToken.TermsExpired) + // token matches + require.Equal(t, existingToken, string(storedToken.Token)) + // all platform default teams are set to nil as the team did not exist + require.Nil(t, storedToken.MacOSDefaultTeamID) + require.Nil(t, storedToken.IOSDefaultTeamID) + require.Nil(t, storedToken.IPadOSDefaultTeamID) + + // the existing host DEP assignment is linked to the token + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT abm_token_id FROM host_dep_assignments WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.NotNil(t, hostTokenID) + require.EqualValues(t, storedToken.ID, *hostTokenID) + }) + + t.Run("ExistingTokenNoMDMConfig", func(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing ABM token + existingToken, md5Checksum := createTokenAndHash() + execNoErr(t, db, `INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('abm_token', ?, ?)`, existingToken, md5Checksum) + + // app config does not have the MDM object + execNoErr(t, db, `UPDATE app_config_json SET json_value = JSON_REMOVE(json_value, '$.mdm')`) + + // create a host with a DEP assignment + hostID := insertHost(t, db, nil) + execNoErr(t, db, `INSERT INTO host_dep_assignments (host_id) VALUES (?)`, hostID) + + // Apply current migration. + applyNext(t, db) + + // ABM token is now soft-deleted in the old storage + var assetDeletedUUID string + err := db.Get(&assetDeletedUUID, `SELECT deletion_uuid FROM mdm_config_assets WHERE name = 'abm_token'`) + require.NoError(t, err) + require.NotEmpty(t, assetDeletedUUID) + + // ABM token is stored in the new storage, with the default config + var storedToken abmToken + err = db.Get(&storedToken, ` +SELECT + id, organization_name, apple_id, terms_expired, renew_at, token, macos_default_team_id, ios_default_team_id, ipados_default_team_id +FROM + abm_tokens +LIMIT 1`) + require.NoError(t, err) + + // we don't have those fields during DB migration + require.Empty(t, storedToken.OrganizationName) + require.Empty(t, storedToken.AppleID) + require.Equal(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), storedToken.RenewAt) + require.False(t, storedToken.TermsExpired) + // token matches + require.Equal(t, existingToken, string(storedToken.Token)) + // all platform default teams are set to nil + require.Nil(t, storedToken.MacOSDefaultTeamID) + require.Nil(t, storedToken.IOSDefaultTeamID) + require.Nil(t, storedToken.IPadOSDefaultTeamID) + + // the existing host DEP assignment is linked to the token + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT abm_token_id FROM host_dep_assignments WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.NotNil(t, hostTokenID) + require.EqualValues(t, storedToken.ID, *hostTokenID) + }) + + t.Run("ExistingTokenCorruptedJSONConfig", func(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing ABM token + existingToken, md5Checksum := createTokenAndHash() + execNoErr(t, db, `INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('abm_token', ?, ?)`, existingToken, md5Checksum) + + // set a corrupted JSON config for ABM + execNoErr(t, db, `UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm', JSON_OBJECT('apple_bm_default_team', 123, 'apple_bm_terms_expired', 'abc'))`) + + // Apply current migration. + applyNext(t, db) + + // ABM token is now soft-deleted in the old storage + var assetDeletedUUID string + err := db.Get(&assetDeletedUUID, `SELECT deletion_uuid FROM mdm_config_assets WHERE name = 'abm_token'`) + require.NoError(t, err) + require.NotEmpty(t, assetDeletedUUID) + + // ABM token is stored in the new storage, with the default config (as the existing one was invalid) + var storedToken abmToken + err = db.Get(&storedToken, ` +SELECT + id, organization_name, apple_id, terms_expired, renew_at, token, macos_default_team_id, ios_default_team_id, ipados_default_team_id +FROM + abm_tokens +LIMIT 1`) + require.NoError(t, err) + + // we don't have those fields during DB migration + require.Empty(t, storedToken.OrganizationName) + require.Empty(t, storedToken.AppleID) + require.Equal(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), storedToken.RenewAt) + require.False(t, storedToken.TermsExpired) + // token matches + require.Equal(t, existingToken, string(storedToken.Token)) + // all platform default teams are set to nil + require.Nil(t, storedToken.MacOSDefaultTeamID) + require.Nil(t, storedToken.IOSDefaultTeamID) + require.Nil(t, storedToken.IPadOSDefaultTeamID) + }) +} diff --git a/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens.go b/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens.go new file mode 100644 index 0000000000..de2fb9157b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens.go @@ -0,0 +1,181 @@ +package tables + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240829165605, Down_20240829165605) +} + +func Up_20240829165605(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE vpp_tokens ( + id int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + organization_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + location varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + renew_at timestamp NOT NULL, + -- encrypted token, encrypted with the FleetConfig.Server.PrivateKey value + token blob NOT NULL, + + -- Note that as per a Slack discussion [1], we opted not to enforce the constraint + -- of team uniqueness in the DB as it quickly got hairy - we'd need a distinct + -- global_or_team_id NOT NULL to be able to enforce it along with the enum, and even + -- then we want to allow multiple entries for a NULL team id with the enum set to 'none' + -- (inactive tokens), and we need to handle the team deletion which only sets team_id + -- automatically via the FK ON DELETE clause. We thought about using a trigger but + -- decided against it given the impacts [2]. We'll instead enforce the constraint in + -- the Go code. + -- [1]: https://fleetdm.slack.com/archives/C03C41L5YEL/p1724073772972669 + -- [2]: https://www.percona.com/blog/how-triggers-may-significantly-affect-the-amount-of-memory-allocated-to-your-mysql-server/ + team_id int(10) UNSIGNED DEFAULT NULL, + + -- null_team_type indicates the special team represented when team_id is NULL, + -- which can be none (VPP token is inactive), "all teams" or the "no team" team. + -- It is important, when setting a non-NULL team_id, to always update this field + -- to "none" so that if the team is deleted, via the ON DELETE SET NULL constraint, + -- the VPP token automatically becomes inactive (and not, e.g. "all teams" which + -- would introduce data inconsistency). + null_team_type ENUM('none', 'allteams', 'noteam') DEFAULT 'none', + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + + UNIQUE KEY idx_vpp_tokens_location (location), + + CONSTRAINT fk_vpp_tokens_team_id + FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE SET NULL +)`) + if err != nil { + return fmt.Errorf("failed to create table vpp_tokens: %w", err) + } + + _, err = tx.Exec(` +ALTER TABLE host_vpp_software_installs + ADD COLUMN vpp_token_id int(10) UNSIGNED NULL, + ADD CONSTRAINT fk_host_vpp_software_installs_vpp_token_id + FOREIGN KEY (vpp_token_id) REFERENCES vpp_tokens(id) ON DELETE SET NULL +`) + if err != nil { + return fmt.Errorf("failed to alter table host_vpp_software_installs: %w", err) + } + + // migrate the existing VPP token (if any) to the new vpp_tokens table + const getVPP = ` +SELECT + value +FROM + mdm_config_assets +WHERE + name = ? AND deletion_uuid = '' +` + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + var token []byte + if err := txx.Get(&token, getVPP, fleet.MDMAssetVPPTokenDeprecated); err != nil { + if errors.Is(err, sql.ErrNoRows) { + // nothing to migrate, exit early + return nil + } + return fmt.Errorf("selecting existing VPP token: %w", err) + } + + // insert the token in the new table, defaulting to "all teams" + const insVPP = ` +INSERT INTO vpp_tokens + ( + organization_name, + location, + renew_at, + token, + team_id, + null_team_type + ) +VALUES + ('', '', DATE('2000-01-01'), ?, NULL, 'allteams') +` + res, err := tx.Exec(insVPP, token) + if err != nil { + return fmt.Errorf("insert existing VPP token into vpp_tokens: %w", err) + } + tokenID, _ := res.LastInsertId() + + // soft-delete the token from the deprecated storage + const delVPP = ` +UPDATE + mdm_config_assets +SET + deleted_at = CURRENT_TIMESTAMP(), + deletion_uuid = ? +WHERE + name = ? AND deletion_uuid = '' +` + deletionUUID := uuid.New().String() + if _, err = tx.Exec(delVPP, deletionUUID, fleet.MDMAssetVPPTokenDeprecated); err != nil { + return fmt.Errorf("delete VPP token from mdm_config_assets: %w", err) + } + + // associate all existing host VPP install requests with the existing token + const updHost = ` +UPDATE + host_vpp_software_installs +SET + vpp_token_id = ?, + updated_at = updated_at +` + if _, err = tx.Exec(updHost, tokenID); err != nil { + return fmt.Errorf("update VPP token link in host_vpp_software_installs: %w", err) + } + + // NOTE: we can't add the token's metadata during migration because we don't + // have the Server.PrivateKey value to decrypt the data from + // mdm_config_assets (the VPP token, once decrypted, contains the metadata we + // need). Enqueue a worker job to complete the migration, back-filling the + // metadata for the migrated token. + const ( + jobName = "db_migration" + taskName = "migrate_vpp_token" + jobStateQueued = "queued" + ) + + type migrateArgs struct { + Task string `json:"task"` + } + argsJSON, err := json.Marshal(migrateArgs{Task: taskName}) + if err != nil { + return fmt.Errorf("failed to JSON marshal the job arguments: %w", err) + } + + // hard-coded timestamps are used so that schema.sql is stable + const query = ` +INSERT INTO jobs ( + name, + args, + state, + error, + not_before, + created_at, + updated_at +) +VALUES (?, ?, ?, '', ?, ?, ?) +` + ts := time.Date(2024, 8, 19, 0, 0, 0, 0, time.UTC) + if _, err := tx.Exec(query, jobName, argsJSON, jobStateQueued, ts, ts, ts); err != nil { + return fmt.Errorf("failed to insert worker job: %w", err) + } + return nil +} + +func Down_20240829165605(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens_test.go b/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens_test.go new file mode 100644 index 0000000000..42ef63fdba --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165605_SupportMultipleVPPTokens_test.go @@ -0,0 +1,163 @@ +package tables + +import ( + "crypto/md5" //nolint:gosec + "database/sql" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestUp_20240829165605(t *testing.T) { + createTokenAndHash := func() (string, []byte) { + tok := uuid.NewString() + h := md5.New() //nolint:gosec + _, _ = h.Write([]byte(tok)) + md5Checksum := h.Sum(nil) + return tok, md5Checksum + } + + type job struct { + ID uint `json:"id" db:"id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` + Name string `json:"name" db:"name"` + Args *json.RawMessage `json:"args" db:"args"` + State string `json:"state" db:"state"` + Retries int `json:"retries" db:"retries"` + Error string `json:"error" db:"error"` + NotBefore time.Time `json:"not_before" db:"not_before"` + } + + type vppToken struct { + ID uint `db:"id"` + OrganizationName string `db:"organization_name"` + Location string `db:"location"` + RenewAt time.Time `db:"renew_at"` + Token []byte `db:"token"` + TeamID *uint `db:"team_id"` + NullTeamType string `db:"null_team_type"` + } + + type jobArgs struct { + Task string `json:"task"` + } + + t.Run("NoExistingToken", func(t *testing.T) { + db := applyUpToPrev(t) + + // create a vpp app + adamID := "abcdEFGH" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, adamID, "darwin") + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, platform) VALUES (?, ?)`, adamID, "darwin") + + // create a host with a VPP install request + hostID := insertHost(t, db, nil) + execNoErr(t, db, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, platform) VALUES (?, ?, ?, ?)`, hostID, adamID, uuid.NewString(), "darwin") + + // there is no pending job of that type at the moment + var jobs []*job + err := db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs WHERE name = 'db_migration'`) + require.NoError(t, err) + require.Empty(t, jobs) + + // Apply current migration. + applyNext(t, db) + + var exists int + // no VPP token in the old storage + err = db.Get(&exists, `SELECT 1 FROM mdm_config_assets WHERE name = 'vpp_token'`) + require.ErrorIs(t, err, sql.ErrNoRows) + // no VPP token in the new storage + err = db.Get(&exists, `SELECT 1 FROM vpp_tokens`) + require.ErrorIs(t, err, sql.ErrNoRows) + + // the existing host install request is not linked + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT vpp_token_id FROM host_vpp_software_installs WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.Nil(t, hostTokenID) + + // there is still no pending job of that type + err = db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs WHERE name = 'db_migration'`) + require.NoError(t, err) + require.Empty(t, jobs) + }) + + t.Run("ExistingTokenAndInstallRequest", func(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing VPP token + existingToken, md5Checksum := createTokenAndHash() + execNoErr(t, db, `INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('vpp_token', ?, ?)`, existingToken, md5Checksum) + + // create a vpp app + adamID := "abcdEFGH" + execNoErr(t, db, `INSERT INTO vpp_apps (adam_id, platform) VALUES (?, ?)`, adamID, "darwin") + execNoErr(t, db, `INSERT INTO vpp_apps_teams (adam_id, platform) VALUES (?, ?)`, adamID, "darwin") + + // create a host with a VPP install request + hostID := insertHost(t, db, nil) + execNoErr(t, db, `INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, platform) VALUES (?, ?, ?, ?)`, hostID, adamID, uuid.NewString(), "darwin") + + // there is no pending job of that type at the moment + var jobs []*job + err := db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs WHERE name = 'db_migration'`) + require.NoError(t, err) + require.Empty(t, jobs) + + // Apply current migration. + applyNext(t, db) + + // VPP token is now soft-deleted in the old storage + var assetDeletedUUID string + err = db.Get(&assetDeletedUUID, `SELECT deletion_uuid FROM mdm_config_assets WHERE name = 'vpp_token'`) + require.NoError(t, err) + require.NotEmpty(t, assetDeletedUUID) + + // VPP token is stored in the new storage, with the expected config + var storedToken vppToken + err = db.Get(&storedToken, ` +SELECT + id, organization_name, location, renew_at, token, team_id, null_team_type +FROM + vpp_tokens +LIMIT 1`) + require.NoError(t, err) + + // we don't have those fields during DB migration + require.NotZero(t, storedToken.ID) + require.Empty(t, storedToken.OrganizationName) + require.Empty(t, storedToken.Location) + require.Equal(t, time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), storedToken.RenewAt) + // token matches + require.Equal(t, existingToken, string(storedToken.Token)) + require.Nil(t, storedToken.TeamID) + require.Equal(t, "allteams", storedToken.NullTeamType) + + // the existing host install request is linked to the token + var hostTokenID *uint + err = db.Get(&hostTokenID, `SELECT vpp_token_id FROM host_vpp_software_installs WHERE host_id = ?`, hostID) + require.NoError(t, err) + require.NotNil(t, hostTokenID) + require.EqualValues(t, storedToken.ID, *hostTokenID) + + // the job was enqueued to finish migrating the token + err = db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs WHERE name = 'db_migration'`) + require.NoError(t, err) + require.Len(t, jobs, 1) + + require.Equal(t, "db_migration", jobs[0].Name) + require.Equal(t, 0, jobs[0].Retries) + require.LessOrEqual(t, jobs[0].NotBefore, time.Now().UTC()) + require.NotNil(t, jobs[0].Args) + + var args jobArgs + err = json.Unmarshal(*jobs[0].Args, &args) + require.NoError(t, err) + require.Equal(t, "migrate_vpp_token", args.Task) + }) +} diff --git a/server/datastore/mysql/migrations/tables/20240829165715_AddUniqueTeamIDToVPPTokens.go b/server/datastore/mysql/migrations/tables/20240829165715_AddUniqueTeamIDToVPPTokens.go new file mode 100644 index 0000000000..3eb8214530 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165715_AddUniqueTeamIDToVPPTokens.go @@ -0,0 +1,22 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829165715, Down_20240829165715) +} + +func Up_20240829165715(tx *sql.Tx) error { + stmt := `ALTER TABLE vpp_tokens ADD UNIQUE KEY idx_vpp_tokens_team_id (team_id)` + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("adding unique constraint to team_id on vpp_tokens: %w", err) + } + return nil +} + +func Down_20240829165715(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants.go b/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants.go new file mode 100644 index 0000000000..7ccb27b06b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants.go @@ -0,0 +1,117 @@ +package tables + +import ( + "database/sql" + "errors" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829165930, Down_20240829165930) +} + +func Up_20240829165930(tx *sql.Tx) error { + // mdm_apple_default_setup_assistants will now track profile_uuids per team + // AND ABM token. + const alterDefaultStmt = ` + ALTER TABLE mdm_apple_default_setup_assistants + ADD COLUMN abm_token_id int unsigned DEFAULT NULL, + DROP KEY idx_mdm_default_setup_assistant_global_or_team_id, + ADD CONSTRAINT idx_mdm_default_setup_assistant_global_or_team_id_abm_token_id + UNIQUE (global_or_team_id, abm_token_id), + ADD CONSTRAINT fk_mdm_default_setup_assistant_abm_token_id + FOREIGN KEY (abm_token_id) REFERENCES abm_tokens(id) ON DELETE CASCADE +` + if _, err := tx.Exec(alterDefaultStmt); err != nil { + return fmt.Errorf("alter mdm_apple_default_setup_assistants to track per ABM token: %w", err) + } + + var abmTokenID uint + if err := tx.QueryRow("SELECT id FROM abm_tokens LIMIT 1").Scan(&abmTokenID); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get existing ABM token ID: %w", err) + } + } + + if abmTokenID > 0 { + // there should only be one ABM token (or none) when this migration runs. + // Timestamp is important in the code logic (it triggers a full DEP sync if + // recently changed), so we ensure it stays the same. + const updateDefaultStmt = ` + UPDATE mdm_apple_default_setup_assistants SET + abm_token_id = ?, + updated_at = updated_at +` + if _, err := tx.Exec(updateDefaultStmt, abmTokenID); err != nil { + return fmt.Errorf("update mdm_apple_default_setup_assistants to set abm token id: %w", err) + } + } + // TODO: should we delete entries without an ABM token ID at this point? And + // make the column NOT NULL? + + const createCustomStmt = ` +CREATE TABLE mdm_apple_setup_assistant_profiles ( + id int unsigned NOT NULL AUTO_INCREMENT, + + -- the corresponding custom setup assistant in mdm_apple_setup_assistants, + -- which is already associated with a team. + setup_assistant_id int unsigned NOT NULL, + + -- the ABM token used to define this profile in the Apple API. + abm_token_id int unsigned NOT NULL, + + -- the profile UUID returned by the Apple API when defined with this ABM + -- token. + profile_uuid varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY idx_mdm_apple_setup_assistant_profiles_asst_id_tok_id (setup_assistant_id, abm_token_id), + CONSTRAINT fk_mdm_apple_setup_assistant_profiles_setup_assistant_id + FOREIGN KEY (setup_assistant_id) REFERENCES mdm_apple_setup_assistants (id) ON DELETE CASCADE, + CONSTRAINT fk_mdm_apple_setup_assistant_profiles_abm_token_id + FOREIGN KEY (abm_token_id) REFERENCES abm_tokens (id) ON DELETE CASCADE +) +` + if _, err := tx.Exec(createCustomStmt); err != nil { + return fmt.Errorf("create mdm_apple_setup_assistant_profiles table: %w", err) + } + + if abmTokenID > 0 { + // migrate any existing profile_uuid to the new table + const insertCustomStmt = ` + INSERT INTO mdm_apple_setup_assistant_profiles ( + setup_assistant_id, + abm_token_id, + profile_uuid, + updated_at + ) + SELECT + mas.id, + ?, + mas.profile_uuid, + mas.updated_at + FROM + mdm_apple_setup_assistants mas +` + if _, err := tx.Exec(insertCustomStmt, abmTokenID); err != nil { + return fmt.Errorf("create mdm_apple_setup_assistant_profiles table: %w", err) + } + } + + const alterCustomStmt = ` + ALTER TABLE mdm_apple_setup_assistants + DROP COLUMN profile_uuid +` + if _, err := tx.Exec(alterCustomStmt); err != nil { + return fmt.Errorf("alter mdm_apple_setup_assistants to drop profile_uuid: %w", err) + } + + return nil +} + +func Down_20240829165930(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants_test.go b/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants_test.go new file mode 100644 index 0000000000..f496d918f7 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829165930_SupportMultipleTokensForSetupAssistants_test.go @@ -0,0 +1,101 @@ +package tables + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20240829165930_None(t *testing.T) { + db := applyUpToPrev(t) + + // Apply current migration. + applyNext(t, db) + + // nothing in the default setup assistant + var count int + err := sqlx.Get(db, &count, `SELECT COUNT(*) FROM mdm_apple_default_setup_assistants`) + require.NoError(t, err) + require.Zero(t, count) + + // nothing in the custom setup assistants + err = sqlx.Get(db, &count, `SELECT COUNT(*) FROM mdm_apple_setup_assistants`) + require.NoError(t, err) + require.Zero(t, count) + + // nothing in the new custom setup table + err = sqlx.Get(db, &count, `SELECT COUNT(*) FROM mdm_apple_setup_assistant_profiles`) + require.NoError(t, err) + require.Zero(t, count) +} + +func TestUp_20240829165930_Existing(t *testing.T) { + db := applyUpToPrev(t) + + // create the single ABM token (can only have 1 when this migration runs) + abmTokID := execNoErrLastID(t, db, "INSERT INTO abm_tokens (organization_name, apple_id, renew_at, token) VALUES (?, ?, ?, ?)", "org", "apple", time.Now(), uuid.NewString()) + + // create a couple teams + tm1 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team1')") + tm2 := execNoErrLastID(t, db, "INSERT INTO teams (name) VALUES ('team2')") + + // setup the default assistant + execNoErr(t, db, `INSERT INTO mdm_apple_enrollment_profiles (token, type, dep_profile) VALUES (?, ?, ?)`, uuid.NewString(), "automatic", "{}") + defProfUUID := uuid.NewString() + execNoErr(t, db, `INSERT INTO mdm_apple_default_setup_assistants (team_id, global_or_team_id, profile_uuid) VALUES (?, ?, ?)`, nil, 0, defProfUUID) + execNoErr(t, db, `INSERT INTO mdm_apple_default_setup_assistants (team_id, global_or_team_id, profile_uuid) VALUES (?, ?, ?)`, tm1, tm1, defProfUUID) + // no profile registered for team 2 + execNoErr(t, db, `INSERT INTO mdm_apple_default_setup_assistants (team_id, global_or_team_id, profile_uuid) VALUES (?, ?, ?)`, tm2, tm2, "") + + // load the default assistant timestamps (ordered by global or team id) + var defTs []time.Time + err := sqlx.Select(db, &defTs, `SELECT updated_at FROM mdm_apple_default_setup_assistants ORDER BY global_or_team_id`) + require.NoError(t, err) + + // create a custom setup assistant for tm1 + asst1ProfUUID := uuid.NewString() + asst1 := execNoErrLastID(t, db, `INSERT INTO mdm_apple_setup_assistants (team_id, global_or_team_id, name, profile, profile_uuid) VALUES (?, ?, ?, ?, ?)`, tm1, tm1, "asst1", "{}", asst1ProfUUID) + + // load the custom assistant timestamp + var asst1Ts time.Time + err = sqlx.Get(db, &asst1Ts, `SELECT updated_at FROM mdm_apple_setup_assistants WHERE id = ?`, asst1) + require.NoError(t, err) + + // Apply current migration. + applyNext(t, db) + + // the default assistants have the ABM token stored, otherwise unchanged + var postDefTs []time.Time + err = sqlx.Select(db, &postDefTs, `SELECT updated_at FROM mdm_apple_default_setup_assistants ORDER BY global_or_team_id`) + require.NoError(t, err) + require.ElementsMatch(t, defTs, postDefTs) + + var count int + err = sqlx.Get(db, &count, `SELECT COUNT(*) FROM mdm_apple_default_setup_assistants WHERE abm_token_id = ?`, abmTokID) + require.NoError(t, err) + require.Equal(t, len(postDefTs), count) + require.Equal(t, 3, count) + + // inserting another default assistant with an existing team+token fails (new unique constraint) + _, err = db.Exec(`INSERT INTO mdm_apple_default_setup_assistants (team_id, global_or_team_id, abm_token_id) VALUES (?, ?, ?)`, nil, 0, abmTokID) + require.Error(t, err) + require.ErrorContains(t, err, "Duplicate entry") + + // the custom assistant entry has been created in the new table with the same timestamp and correct token ID + var customAssts []struct { + SetupAssistantID uint `db:"setup_assistant_id"` + ABMTokenID uint `db:"abm_token_id"` + ProfileUUID string `db:"profile_uuid"` + UpdatedAt time.Time `db:"updated_at"` + } + err = sqlx.Select(db, &customAssts, `SELECT setup_assistant_id, abm_token_id, profile_uuid, updated_at FROM mdm_apple_setup_assistant_profiles`) + require.NoError(t, err) + require.Len(t, customAssts, 1) + require.EqualValues(t, asst1, customAssts[0].SetupAssistantID) + require.EqualValues(t, abmTokID, customAssts[0].ABMTokenID) + require.Equal(t, asst1ProfUUID, customAssts[0].ProfileUUID) + require.Equal(t, asst1Ts, customAssts[0].UpdatedAt) +} diff --git a/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable.go b/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable.go new file mode 100644 index 0000000000..c4c92b203b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable.go @@ -0,0 +1,51 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170023, Down_20240829170023) +} + +func Up_20240829170023(tx *sql.Tx) error { + _, err := tx.Exec(` +CREATE TABLE vpp_token_teams ( + id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, + vpp_token_id int unsigned NOT NULL, + team_id int unsigned, + null_team_type enum('none','allteams','noteam') COLLATE utf8mb4_unicode_ci DEFAULT 'none', + UNIQUE KEY idx_vpp_token_teams_team_id (team_id), + -- Note that this is only a partial constraint. There can be only + -- one token per team, but the team "No team" and "all teams" have + -- to be checked manually in go code + CONSTRAINT fk_vpp_token_teams_team_id FOREIGN KEY (team_id) REFERENCES teams (id) ON DELETE CASCADE, + CONSTRAINT fk_vpp_token_teams_vpp_token_id FOREIGN KEY (vpp_token_id) REFERENCES vpp_tokens (id) ON DELETE CASCADE +); + +INSERT INTO vpp_token_teams ( + vpp_token_id, + team_id, + null_team_type +) SELECT + id, + team_id, + null_team_type +FROM vpp_tokens; + +ALTER TABLE vpp_tokens DROP FOREIGN KEY fk_vpp_tokens_team_id; +ALTER TABLE vpp_tokens DROP CONSTRAINT idx_vpp_tokens_team_id; +ALTER TABLE vpp_tokens DROP COLUMN team_id; +ALTER TABLE vpp_tokens DROP COLUMN null_team_type; +`) + if err != nil { + return fmt.Errorf("migrating vpp_tokens associations to join table: %w", err) + } + + return nil +} + +func Down_20240829170023(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable_test.go b/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable_test.go new file mode 100644 index 0000000000..8d4d65e544 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170023_CreateVPPTokenTeamsJoinTable_test.go @@ -0,0 +1,118 @@ +package tables + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240829170023(t *testing.T) { + db := applyUpToPrev(t) + + _, err := db.Exec("INSERT INTO teams (name) VALUES (?)", "team1") + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO vpp_tokens ( + organization_name, + location, + renew_at, + token, + team_id, + null_team_type + ) VALUES + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?) + `, + "org1", "loc1", "2030-01-01 10:10:10", "blob1", 1, "none", + "org2", "loc2", "2030-02-01 10:10:10", "blob2", nil, "noteam", + "org3", "loc3", "2030-03-01 10:10:10", "blob3", nil, "allteams", + ) + require.NoError(t, err) + + var count []int + err = db.Select(&count, "SELECT COUNT(*) FROM vpp_tokens") + require.NoError(t, err) + require.Equal(t, 3, count[0]) + + // Apply current migration. + applyNext(t, db) + + var sel []selresult + + err = db.Select(&sel, ` + SELECT + v.organization_name, + v.token, + j.vpp_token_id, + j.team_id, + j.null_team_type + FROM + vpp_tokens v + LEFT OUTER JOIN + vpp_token_teams j + ON + v.id = j.vpp_token_id + `) + require.NoError(t, err) + require.Len(t, sel, 3) + + expected := []selresult{ + { + // Make assumptions about autoincrement IDs + TokenID: 1, + Org: "org1", + Token: "blob1", + TeamID: ptr.Int(1), + NullTeam: "none", + }, + { + TokenID: 2, + Org: "org2", + Token: "blob2", + TeamID: nil, + NullTeam: "noteam", + }, + { + TokenID: 3, + Org: "org3", + Token: "blob3", + TeamID: nil, + NullTeam: "allteams", + }, + } + + for _, exp := range expected { + actual := find(t, sel, exp.TokenID) + assert.Equal(t, exp.Org, actual.Org) + assert.Equal(t, exp.Token, actual.Token) + if exp.TeamID == nil { + assert.Nil(t, actual.TeamID) + } else { + assert.Equal(t, *exp.TeamID, *actual.TeamID) + } + assert.Equal(t, exp.NullTeam, actual.NullTeam) + } +} + +type selresult struct { + TokenID int `db:"vpp_token_id"` + Org string `db:"organization_name"` + Token string `db:"token"` + TeamID *int `db:"team_id"` + NullTeam string `db:"null_team_type"` +} + +func find(t *testing.T, arr []selresult, tokenID int) selresult { + for _, thing := range arr { + if thing.TokenID == tokenID { + return thing + } + } + + t.Errorf("failed to find result with tokenID %d", tokenID) + return selresult{} +} diff --git a/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go new file mode 100644 index 0000000000..ea367f59ea --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go @@ -0,0 +1,43 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170033, Down_20240829170033) +} + +func Up_20240829170033(tx *sql.Tx) error { + stmtAddColumn := ` +ALTER TABLE vpp_apps_teams + ADD COLUMN vpp_token_id int(10) UNSIGNED NOT NULL` + + stmtAssociate := `UPDATE vpp_apps_teams SET vpp_token_id = (SELECT id FROM vpp_tokens LIMIT 1)` + + stmtAddConstraint := ` +ALTER TABLE vpp_apps_teams + ADD CONSTRAINT fk_vpp_apps_teams_vpp_token_id + FOREIGN KEY (vpp_token_id) REFERENCES vpp_tokens(id) ON DELETE CASCADE` + + if _, err := tx.Exec(stmtAddColumn); err != nil { + return fmt.Errorf("failed to add vpp_token_id column to table: %w", err) + } + + // Associate apps with the first token available. If we're + // migrating from single-token VPP this should be correct. + if _, err := tx.Exec(stmtAssociate); err != nil { + return fmt.Errorf("failed to associate vpp apps with first token: %w", err) + } + + if _, err := tx.Exec(stmtAddConstraint); err != nil { + return fmt.Errorf("failed to add vpp token id constraint: %w", err) + } + + return nil +} + +func Down_20240829170033(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go b/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go new file mode 100644 index 0000000000..ac1fd41802 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170044, Down_20240829170044) +} + +func Up_20240829170044(tx *sql.Tx) error { + if _, err := tx.Exec(` + ALTER TABLE policies + ADD COLUMN software_installer_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY fk_policies_software_installer_id (software_installer_id) REFERENCES software_installers (id); + `); err != nil { + return fmt.Errorf("failed to add software_installer_id to policies: %w", err) + } + + // We store `user_name` and `user_email` in case the user is deleted from Fleet (`user_id` set to NULL). + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL, + ADD COLUMN user_name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD COLUMN user_email VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD CONSTRAINT fk_software_installers_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL; + `); err != nil { + return fmt.Errorf("failed to add user_id to software_installers: %w", err) + } + + return nil +} + +func Down_20240829170044(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go new file mode 100644 index 0000000000..19eed6bee7 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go @@ -0,0 +1,40 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240905105135, Down_20240905105135) +} + +func Up_20240905105135(tx *sql.Tx) error { + // The AUTO_INCREMENT columns are used to determine if a row was updated by an INSERT ... ON DUPLICATE KEY UPDATE statement. + // This is needed because we are currently using CLIENT_FOUND_ROWS option to determine if a row was found. + // And in order to find if the row was updated, we need to check LAST_INSERT_ID(). + // MySQL docs: https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html + + if !columnExists(tx, "mdm_windows_configuration_profiles", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_windows_configuration_profiles +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_windows_configuration_profiles: %w", err) + } + } + + if !columnExists(tx, "mdm_apple_declarations", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_apple_declarations +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_apple_declarations: %w", err) + } + } + return nil +} + +func Down_20240905105135(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go b/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go new file mode 100644 index 0000000000..2da6ca5bdb --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240905140514, Down_20240905140514) +} + +func Up_20240905140514(tx *sql.Tx) error { + // The new 'url' column will only be set for software uploaded in batch via GitOps. + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN url VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''; + `); err != nil { + return fmt.Errorf("failed to add url to software_installers: %w", err) + } + return nil +} + +func Down_20240905140514(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go new file mode 100644 index 0000000000..e9596f5359 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages.go @@ -0,0 +1,153 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" +) + +func init() { + MigrationClient.AddMigration(Up_20240905200000, Down_20240905200000) +} + +const placeholderUninstallScript = "# This script will be automatically updated within the next hour\nexit 1" +const placeholderUninstallScriptWindows = "# This script will be automatically updated within the next hour\nExit 1" + +func Up_20240905200000(tx *sql.Tx) error { + if _, err := tx.Exec(` +ALTER TABLE software_installers +ADD COLUMN package_ids TEXT COLLATE utf8mb4_unicode_ci NOT NULL, +ADD COLUMN extension VARCHAR(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', +ADD COLUMN uninstall_script_content_id int unsigned NOT NULL, +ADD COLUMN updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), +MODIFY COLUMN uploaded_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + `); err != nil { + return fmt.Errorf("failed to alter software_installers: %w", err) + } + + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + + // Add dummy uninstall scripts if needed -- these will be updated later by a cron job + var result []int + if err := txx.Select(&result, `SELECT 1 FROM software_installers WHERE platform IN ('linux', 'darwin')`); err != nil { + return fmt.Errorf("failed to check software installers for linux or darwin: %w", err) + } + if len(result) > 0 { + linuxScriptID, err := getOrInsertScript(txx, placeholderUninstallScript) + if err != nil { + return err + } + // Update software installers with the scripts + if _, err := tx.Exec(`UPDATE software_installers SET uninstall_script_content_id = ? WHERE platform IN ('linux', 'darwin')`, + linuxScriptID); err != nil { + return fmt.Errorf("failed to update software installers: %w", err) + } + } + + if err := txx.Select(&result, `SELECT 1 FROM software_installers WHERE platform IN ('windows')`); err != nil { + return fmt.Errorf("failed to check software installers for windows: %w", err) + } + if len(result) > 0 { + windowsScriptID, err := getOrInsertScript(txx, placeholderUninstallScriptWindows) + if err != nil { + return err + } + // Update software installers with the scripts + if _, err := tx.Exec(`UPDATE software_installers SET uninstall_script_content_id = ? WHERE platform IN ('windows')`, + windowsScriptID); err != nil { + return fmt.Errorf("failed to update windows software installers: %w", err) + } + } + + // Add best-guess installer extensions if needed -- these will be updated later by a cron job to file contents based types + // Also set existing updated_at timestamps to uploaded_at since installers were previously immutable + if _, err := tx.Exec(`UPDATE software_installers SET extension = SUBSTRING_INDEX(filename,'.',-1), updated_at = uploaded_at`); err != nil { + return fmt.Errorf("failed to backfill best-guess installer extensions: %w", err) + } + + // Add foreign key + if _, err := tx.Exec(` +ALTER TABLE software_installers +ADD CONSTRAINT fk_uninstall_script_content_id + FOREIGN KEY (uninstall_script_content_id) + REFERENCES script_contents(id) + ON DELETE RESTRICT ON UPDATE CASCADE`); err != nil { + return fmt.Errorf("failed to add foreign key to software_installers: %w", err) + } + + if _, err := tx.Exec(` +ALTER TABLE host_software_installs +ADD COLUMN uninstall_script_output TEXT COLLATE utf8mb4_unicode_ci, +ADD COLUMN uninstall_script_exit_code INT DEFAULT NULL, +ADD COLUMN uninstall TINYINT UNSIGNED NOT NULL DEFAULT 0, +ADD COLUMN status ENUM('pending_install', 'failed_install', 'installed', 'pending_uninstall', 'failed_uninstall') +GENERATED ALWAYS AS ( +CASE + WHEN removed = 1 THEN NULL + + WHEN post_install_script_exit_code IS NOT NULL AND + post_install_script_exit_code = 0 THEN 'installed' + + WHEN post_install_script_exit_code IS NOT NULL AND + post_install_script_exit_code != 0 THEN 'failed_install' + + WHEN install_script_exit_code IS NOT NULL AND + install_script_exit_code = 0 THEN 'installed' + + WHEN install_script_exit_code IS NOT NULL AND + install_script_exit_code != 0 THEN 'failed_install' + + WHEN pre_install_query_output IS NOT NULL AND + pre_install_query_output = '' THEN 'failed_install' + + WHEN host_id IS NOT NULL AND uninstall = 0 THEN 'pending_install' + + WHEN uninstall_script_exit_code IS NOT NULL AND + uninstall_script_exit_code != 0 THEN 'failed_uninstall' + + WHEN uninstall_script_exit_code IS NOT NULL AND + uninstall_script_exit_code = 0 THEN NULL -- available for install again + + WHEN host_id IS NOT NULL AND uninstall = 1 THEN 'pending_uninstall' + + ELSE NULL -- not installed from Fleet installer or successfully uninstalled +END +) STORED NULL, +MODIFY COLUMN created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), +MODIFY COLUMN updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), +MODIFY COLUMN host_deleted_at TIMESTAMP(6) NULL DEFAULT NULL + `); err != nil { + return fmt.Errorf("failed to alter host_software_installs: %w", err) + } + + return nil +} + +func getOrInsertScript(txx sqlx.Tx, script string) (int64, error) { + var ids []int64 + // check is script already exists + csum := md5ChecksumScriptContent(script) + if err := txx.Select(&ids, `SELECT id FROM script_contents WHERE md5_checksum = UNHEX(?)`, csum); err != nil { + return 0, fmt.Errorf("failed to find script contents: %w", err) + } + var scriptID int64 + if len(ids) > 0 { + scriptID = ids[0] + } else { + // create new script + var result sql.Result + var err error + if result, err = txx.Exec(`INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(?), ?)`, csum, + script); err != nil { + return 0, fmt.Errorf("failed to insert script contents: %w", err) + } + scriptID, _ = result.LastInsertId() + } + return scriptID, nil +} + +func Down_20240905200000(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go new file mode 100644 index 0000000000..0a9af607bc --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200000_UninstallPackages_test.go @@ -0,0 +1,106 @@ +package tables + +import ( + "testing" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240905200000(t *testing.T) { + db := applyUpToPrev(t) + + // Create host + insertHostStmt := ` + INSERT INTO hosts ( + hostname, uuid, platform, osquery_version, os_version, build, platform_like, code_name, + cpu_type, cpu_subtype, cpu_brand, hardware_vendor, hardware_model, hardware_version, + hardware_serial, computer_name, team_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + hostName := "Dummy Hostname" + hostUUID := "12345678-1234-1234-1234-123456789012" + hostPlatform := "darwin" + osqueryVer := "5.9.1" + osVersion := "Windows 10" + buildVersion := "10.0.19042.1234" + platformLike := "apple" + codeName := "20H2" + cpuType := "x86_64" + cpuSubtype := "x86_64" + cpuBrand := "Intel" + hwVendor := "Dell Inc." + hwModel := "OptiPlex 7090" + hwVersion := "1.0" + hwSerial := "ABCDEFGHIJ" + computerName := "DESKTOP-TEST" + + hostID1 := execNoErrLastID(t, db, insertHostStmt, hostName, hostUUID, hostPlatform, osqueryVer, + osVersion, buildVersion, platformLike, codeName, cpuType, cpuSubtype, cpuBrand, hwVendor, hwModel, hwVersion, hwSerial, + computerName, nil) + + dataStmts := ` + INSERT INTO script_contents (id, md5_checksum, contents) VALUES + (1, 'checksum', 'script content'); + + INSERT INTO software_titles (id, name, source, browser) VALUES + (1, 'Foo.app', 'apps', ''), + (2, 'Go', 'deb_packages', ''), + (3, 'Microsoft Teams.exe', 'programs', ''); + + INSERT INTO software_installers + (id, title_id, filename, version, platform, install_script_content_id, storage_id) + VALUES + (1, 1, 'foo-installer.pkg', '1.1', 'darwin', 1, 'storage-id'), + (2, 2, 'go-installer.deb', '2.2', 'linux', 1, 'storage-id'), + (3, 3, 'teams-installer.exe', '3.3', 'windows', 1, 'storage-id'); + ` + _, err := db.Exec(dataStmts) + require.NoError(t, err) + + tx, err := db.Begin() + require.NoError(t, err) + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + scriptID, err := getOrInsertScript(txx, placeholderUninstallScript) + require.NoError(t, err) + err = tx.Commit() + require.NoError(t, err) + + hsiStmt := ` + INSERT INTO host_software_installs ( + host_id, + execution_id, + software_installer_id, + install_script_exit_code + ) VALUES (?, ?, ?, ?)` + hsi1 := execNoErrLastID(t, db, hsiStmt, hostID1, "execution-id1", 1, 0) + + // Apply current migration. + applyNext(t, db) + + var scriptIDs []int64 + err = db.Select(&scriptIDs, "SELECT uninstall_script_content_id FROM software_installers WHERE id IN (1, 2)") + require.NoError(t, err) + require.ElementsMatch(t, []int64{scriptID, scriptID}, scriptIDs) + + var windowsScript string + err = db.Get(&windowsScript, ` + SELECT contents FROM script_contents sc + INNER JOIN software_installers si ON sc.id = si.uninstall_script_content_id + WHERE si.id = 3`) + require.NoError(t, err) + assert.Equal(t, placeholderUninstallScriptWindows, windowsScript) + + var extension string + err = db.Get(&extension, `SELECT extension FROM software_installers si WHERE si.id = 3 AND updated_at = uploaded_at`) + require.NoError(t, err) + assert.Equal(t, "exe", extension) + + var status string + err = db.Get(&status, "SELECT status FROM host_software_installs WHERE id = ?", hsi1) + require.NoError(t, err) + assert.Equal(t, "installed", status) + +} diff --git a/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go new file mode 100644 index 0000000000..d24592b9f0 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam.go @@ -0,0 +1,78 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240905200001, Down_20240905200001) +} + +func Up_20240905200001(tx *sql.Tx) error { + // + // Changes in `policies` and `policy_stats` to support policies for "No team". + // "No team" here means policies that run on hosts that belong to no team (hosts.team_id = NULL) + // + // `policies`: + // - team_id = NULL means the policy is a "Global policy" (aka "All teams" policy). + // - team_id > 0 means the policy is a team policy. + // - team_id = 0 means the policy is a "No team" policy. + // + // `policy_stats`: + // - For "Global policies": + // - inherited_team_id_char = 'global', inherited_team_id = NULL are the stats for the policy's global domain. + // - inherited_team_id_char = '', inherited_team_id = are the stats of the policy on a specific team domain. + // - inherited_team_id_car = '0', inherited_team_id = 0 are the stats of the policy on the "No team" domain. + // - For "Team policies" (for team policies there's always just one row in this table): + // - inherited_team_id_char = 'global', inherited_team_id = NULL are the stats for the team policy. + // + + // Drop foreign key on policies table to teams to allow for team_id = 0 to represent "No team". + referencedTables := map[string]struct{}{"teams": {}} + table := "policies" + constraints, err := constraintsForTable(tx, table, referencedTables) + if err != nil { + return err + } + if len(constraints) != 1 { + return errors.New("policies foreign key to teams not found") + } + if _, err := tx.Exec(fmt.Sprintf(` + ALTER TABLE policies + DROP FOREIGN KEY %s; + `, constraints[0])); err != nil { + return fmt.Errorf("failed to drop policies foreign key to teams: %w", err) + } + + // Allow `inherited_team_id` to be NULL to represent global policy stats on the global domain, and `inherited_team_id = 0` + // to represent global policy stats on the "No team" domain. + // Add `inherited_team_id_char` as generated column to add uniqueness constraint to the table for policies on each domain. + if _, err := tx.Exec(` + ALTER TABLE policy_stats + DROP INDEX policy_team_unique, + MODIFY inherited_team_id INT UNSIGNED NULL, + ADD COLUMN inherited_team_id_char char(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + GENERATED ALWAYS AS (IF(inherited_team_id IS NULL, 'global', CONVERT(inherited_team_id, CHAR))), + ADD UNIQUE KEY (policy_id, inherited_team_id_char); + `); err != nil { + return fmt.Errorf("failed to modify inherited_team_id in policy_stats: %w", err) + } + + // Update inherited_team_id from `0` to `NULL` to allow storing stats for the "No team" domain as `inherited_team_id = 0`. + if _, err := tx.Exec(` + UPDATE policy_stats + SET inherited_team_id = NULL + WHERE inherited_team_id = 0; + `); err != nil { + return fmt.Errorf("failed to update policy_stats: %w", err) + } + + return nil +} + +func Down_20240905200001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go new file mode 100644 index 0000000000..90a2495ea5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905200001_AddPoliciesToNoTeam_test.go @@ -0,0 +1,86 @@ +package tables + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240905200001(t *testing.T) { + db := applyUpToPrev(t) + + team1ID := uint(execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ('team1');`)) + globalPolicy0 := uint(execNoErrLastID(t, db, + `INSERT INTO policies (name, query, description, checksum) VALUES + ('globalPolicy0', 'SELECT 0', 'Description', 'checksum');`, + )) + policy1Team1 := uint(execNoErrLastID(t, db, + `INSERT INTO policies (name, query, description, team_id, checksum) + VALUES ('policy1Team1', 'SELECT 1', 'Description', ?, 'checksum2');`, + team1ID, + )) + + // Insert policy stats for a global policy. + execNoErr(t, db, + `INSERT INTO policy_stats + (policy_id, inherited_team_id, passing_host_count, failing_host_count) + VALUES + (?, ?, 1, 2), (?, ?, 3, 4);`, + globalPolicy0, + 0, + globalPolicy0, + policy1Team1, + ) + // Insert policy stats for a team policy. + execNoErr(t, db, + `INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) + VALUES (?, ?, 5, 6);`, + policy1Team1, + 0, + ) + + applyNext(t, db) + + // Check the policy_stats for global have been migrated correctly. + var results []struct { + PolicyID uint `db:"policy_id"` + InheritedTeamID *uint `db:"inherited_team_id"` + InheritedTeamIDChar string `db:"inherited_team_id_char"` + PassingHostCount uint `db:"passing_host_count"` + FailingHostCount uint `db:"failing_host_count"` + } + err := db.Select(&results, + `SELECT policy_id, inherited_team_id, inherited_team_id_char, passing_host_count, failing_host_count + FROM policy_stats ORDER BY policy_id ASC;`, + ) + require.NoError(t, err) + require.Len(t, results, 3) + + require.Equal(t, globalPolicy0, results[0].PolicyID) + require.Nil(t, results[0].InheritedTeamID) + require.Equal(t, "global", results[0].InheritedTeamIDChar) + require.Equal(t, uint(1), results[0].PassingHostCount) + require.Equal(t, uint(2), results[0].FailingHostCount) + + require.Equal(t, globalPolicy0, results[1].PolicyID) + require.NotNil(t, results[1].InheritedTeamID) + require.Equal(t, policy1Team1, *results[1].InheritedTeamID) + require.Equal(t, strconv.FormatUint(uint64(policy1Team1), 10), results[1].InheritedTeamIDChar) + require.Equal(t, uint(3), results[1].PassingHostCount) + require.Equal(t, uint(4), results[1].FailingHostCount) + + require.Equal(t, policy1Team1, results[2].PolicyID) + require.Nil(t, results[2].InheritedTeamID) + require.Equal(t, "global", results[2].InheritedTeamIDChar) + require.Equal(t, uint(5), results[2].PassingHostCount) + require.Equal(t, uint(6), results[2].FailingHostCount) + + // The team can be deleted, and the policy won't be automatically deleted. + execNoErr(t, db, + `DELETE FROM teams;`, + ) + var ok bool + err = db.Get(&ok, `SELECT 1 FROM policies WHERE id = ?;`, policy1Team1) + require.NoError(t, err) +} diff --git a/server/datastore/mysql/migrations/tables/migration.go b/server/datastore/mysql/migrations/tables/migration.go index a7872b5f7b..ca62d5cd9b 100644 --- a/server/datastore/mysql/migrations/tables/migration.go +++ b/server/datastore/mysql/migrations/tables/migration.go @@ -50,6 +50,27 @@ WHERE return count > 0 } +func tableExists(tx *sql.Tx, table string) bool { + var count int + err := tx.QueryRow( + ` +SELECT + count(*) +FROM + information_schema.columns +WHERE + TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? +`, + table, + ).Scan(&count) + if err != nil { + return false + } + + return count > 0 +} + func indexExists(tx *sqlx.DB, table, index string) bool { var count int err := tx.QueryRow(` @@ -66,6 +87,22 @@ AND index_name = ? return count > 0 } +func indexExistsTx(tx *sql.Tx, table, index string) bool { + var count int + err := tx.QueryRow(` +SELECT COUNT(1) +FROM INFORMATION_SCHEMA.STATISTICS +WHERE table_schema = DATABASE() +AND table_name = ? +AND index_name = ? +`, table, index).Scan(&count) + if err != nil { + return false + } + + return count > 0 +} + // updateAppConfigJSON updates the `json_value` stored in the `app_config_json` after applying the // supplied callback to the current config object. func updateAppConfigJSON(tx *sql.Tx, fn func(config *fleet.AppConfig) error) error { diff --git a/server/datastore/mysql/migrations/tables/migration_test.go b/server/datastore/mysql/migrations/tables/migration_test.go index ffe2d16866..6ea84ec50c 100644 --- a/server/datastore/mysql/migrations/tables/migration_test.go +++ b/server/datastore/mysql/migrations/tables/migration_test.go @@ -48,7 +48,9 @@ func newDBConnForTests(t *testing.T) *sqlx.DB { fmt.Sprintf("%s:%s@tcp(%s)/?charset=utf8mb4&parseTime=true&loc=UTC&multiStatements=true", testUsername, testPassword, testAddress), ) require.NoError(t, err) - _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s; CREATE DATABASE %s; USE %s;", t.Name(), t.Name(), t.Name())) + + name := strings.ReplaceAll(strings.ReplaceAll(t.Name(), "/", "_"), " ", "_") + _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s; CREATE DATABASE %s; USE %s;", name, name, name)) require.NoError(t, err) return db } @@ -56,8 +58,25 @@ func newDBConnForTests(t *testing.T) *sqlx.DB { func getMigrationVersion(t *testing.T) int64 { // Migration test functions look like this: // func TestUp_20231109115838(t *testing.T) - // so this extracts the timestamp part only. - v, err := strconv.Atoi(strings.TrimPrefix(t.Name(), "TestUp_")) + // + // and multiple unit tests for the same migration version can be done by + // following this naming pattern: + // func TestUp_20231109115838_scenario1(t *testing.T) + // func TestUp_20231109115838_scenario2(t *testing.T) + // + // Note that sub-tests can also be used, so: + // func TestUp_20231109115838(t *testing.T) { + // t.Run("scenario1", func(t *testing.T) {...} + // } + // also works (calling applyUpToPrev in each sub-test to create a new test + // database). + // + // This extracts the migration version (timestamp) from the test name. + + baseName, _, _ := strings.Cut(t.Name(), "/") + withoutPrefix := strings.TrimPrefix(baseName, "TestUp_") + timestampPart, _, _ := strings.Cut(withoutPrefix, "_") + v, err := strconv.Atoi(timestampPart) require.NoError(t, err) return int64(v) } diff --git a/server/datastore/mysql/migrations_test.go b/server/datastore/mysql/migrations_test.go index a8a010dc8f..e782e0d2b2 100644 --- a/server/datastore/mysql/migrations_test.go +++ b/server/datastore/mysql/migrations_test.go @@ -64,7 +64,7 @@ func TestMigrations(t *testing.T) { // Dump schema to dumpfile cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysqldump", "-u"+testUsername, "-p"+testPassword, "TestMigrations", "--compact", "--skip-comments", ) diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 76c67a34e2..3acfa46f63 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -82,6 +82,8 @@ type Datastore struct { testDeleteMDMProfilesBatchSize int // for tests, set to override the default batch size. testUpsertMDMDesiredProfilesBatchSize int + // for tests set to override the default batch size. + testSelectMDMProfilesBatchSize int // set this in tests to simulate an error at various stages in the // batchSetMDMAppleProfilesDB execution: if the string starts with "insert", it @@ -160,6 +162,22 @@ func (ds *Datastore) loadOrPrepareStmt(ctx context.Context, query string) *sqlx. return stmt } +func (ds *Datastore) deleteCachedStmt(query string) { + ds.stmtCacheMu.Lock() + defer ds.stmtCacheMu.Unlock() + stmt, ok := ds.stmtCache[query] + if ok { + if err := stmt.Close(); err != nil { + level.Error(ds.logger).Log( + "msg", "failed to close prepared statement before deleting it", + "query", query, + "err", err, + ) + } + delete(ds.stmtCache, query) + } +} + // NewMDMAppleSCEPDepot returns a scep_depot.Depot that uses the Datastore // underlying MySQL writer *sql.DB. func (ds *Datastore) NewSCEPDepot() (scep_depot.Depot, error) { @@ -1224,21 +1242,7 @@ func (ds *Datastore) ProcessList(ctx context.Context) ([]fleet.MySQLProcess, err return processList, nil } -func insertOnDuplicateDidInsert(res sql.Result) bool { - // Note that connection string sets CLIENT_FOUND_ROWS (see - // generateMysqlConnectionString in this package), so LastInsertId is 0 - // and RowsAffected 1 when a row is set to its current values. - // - // See [the docs][1] or @mna's comment in `insertOnDuplicateDidUpdate` - // below for more details - // - // [1]: https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html - lastID, _ := res.LastInsertId() - affected, _ := res.RowsAffected() - return lastID != 0 && affected == 1 -} - -func insertOnDuplicateDidUpdate(res sql.Result) bool { +func insertOnDuplicateDidInsertOrUpdate(res sql.Result) bool { // From mysql's documentation: // // With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if @@ -1248,7 +1252,10 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { // connecting to mysqld, the affected-rows value is 1 (not 0) if an // existing row is set to its current values. // - // https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html + // If a table contains an AUTO_INCREMENT column and INSERT ... ON DUPLICATE KEY UPDATE + // inserts or updates a row, the LAST_INSERT_ID() function returns the AUTO_INCREMENT value. + // + // https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html // // Note that connection string sets CLIENT_FOUND_ROWS (see // generateMysqlConnectionString in this package), so it does return 1 when @@ -1263,7 +1270,8 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { lastID, _ := res.LastInsertId() aff, _ := res.RowsAffected() - return lastID == 0 || aff != 1 + // something was updated (lastID != 0) AND row was found (aff == 1 or higher if more rows were found) + return lastID != 0 && aff > 0 } type parameterizedStmt struct { diff --git a/server/datastore/mysql/mysql_test.go b/server/datastore/mysql/mysql_test.go index 1218d4650d..228715ffff 100644 --- a/server/datastore/mysql/mysql_test.go +++ b/server/datastore/mysql/mysql_test.go @@ -1301,3 +1301,85 @@ func TestBatchProcessDB(t *testing.T) { require.Equal(t, 2, callCount) }) } + +func TestGetContextTryStmt(t *testing.T) { + ctx := context.Background() + + dbMock, ds := mockDatastore(t) + ds.stmtCache = map[string]*sqlx.Stmt{} + + t.Run("get with unknown statement error", func(t *testing.T) { + count := 0 + query := "SELECT 1" + + // first call to cache the statement + dbMock.ExpectPrepare(query) + mockResult := sqlmock.NewRows([]string{query}) + mockResult.AddRow("1") + dbMock.ExpectQuery(query).WillReturnRows(mockResult) + err := ds.getContextTryStmt(ctx, &count, query) + require.NoError(t, err) + require.NoError(t, dbMock.ExpectationsWereMet()) + + // verify that the statement was cached + stmt := ds.loadOrPrepareStmt(ctx, query) + require.NotNil(t, stmt) + + // call again to trigger the unknown statement error and ensure it retries + // first query, make it fail + queryMock := dbMock.ExpectQuery(query) + mySQLErr := &mysql.MySQLError{ + Number: mysqlerr.ER_UNKNOWN_STMT_HANDLER, + } + queryMock.WillReturnError(mySQLErr) + + // after the failure, a second call is made, this time without + // the prepared statement + mockResult = sqlmock.NewRows([]string{query}) + mockResult.AddRow("1") + dbMock.ExpectQuery(query).WillReturnRows(mockResult) + + // make the call and verify we removed the prepared statement + err = ds.getContextTryStmt(ctx, &count, query) + require.NoError(t, err) + require.NoError(t, dbMock.ExpectationsWereMet()) + stmt = ds.loadOrPrepareStmt(ctx, query) + require.Nil(t, stmt) + }) + + t.Run("get with other error", func(t *testing.T) { + dbMock, ds := mockDatastore(t) + ds.stmtCache = map[string]*sqlx.Stmt{} + count := 0 + query := "SELECT 1" + + // first call to cache the statement + dbMock.ExpectPrepare(query) + mockResult := sqlmock.NewRows([]string{query}) + mockResult.AddRow("1") + dbMock.ExpectQuery(query).WillReturnRows(mockResult) + err := ds.getContextTryStmt(ctx, &count, query) + require.NoError(t, err) + require.Equal(t, 1, count) + require.NoError(t, dbMock.ExpectationsWereMet()) + + // verify that the statement was cached + stmt := ds.loadOrPrepareStmt(ctx, query) + require.NotNil(t, stmt) + + // return a duplicate error + queryMock := dbMock.ExpectQuery(query) + mySQLErr := &mysql.MySQLError{ + Number: mysqlerr.ER_DUP_ENTRY, + } + queryMock.WillReturnError(mySQLErr) + + count = 0 + err = ds.getContextTryStmt(ctx, &count, query) + require.ErrorIs(t, mySQLErr, err) + require.NoError(t, dbMock.ExpectationsWereMet()) + stmt = ds.loadOrPrepareStmt(ctx, query) + require.NotNil(t, stmt) + }) + +} diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index 2d89ac3f1c..e032cab539 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + abmctx "github.com/fleetdm/fleet/v4/server/contexts/apple_bm" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/assets" @@ -135,6 +136,10 @@ func (s *NanoMDMStorage) GetAllMDMConfigAssetsByName(ctx context.Context, assetN return s.ds.GetAllMDMConfigAssetsByName(ctx, assetNames) } +func (s *NanoMDMStorage) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { + return s.ds.GetABMTokenByOrgName(ctx, orgName) +} + // NewMDMAppleDEPStorage returns a MySQL nanodep storage that uses the Datastore // underlying MySQL writer *sql.DB. func (ds *Datastore) NewMDMAppleDEPStorage() (*NanoDEPStorage, error) { @@ -156,9 +161,16 @@ type NanoDEPStorage struct { ds fleet.Datastore } -// RetrieveAuthTokens partially implements nanodep.AuthTokensRetriever. +// RetrieveAuthTokens partially implements nanodep.AuthTokensRetriever. NOTE: this method will first +// check the context for an ABM token; if it doesn't find one, it will fall back to checking the DB. +// This is so we can use the existing DEP client machinery without major changes. See +// https://github.com/fleetdm/fleet/issues/21177 for more details. func (s *NanoDEPStorage) RetrieveAuthTokens(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) { - token, err := assets.ABMToken(ctx, s.ds) + if ctxTok, ok := abmctx.FromContext(ctx); ok { + return ctxTok, nil + } + + token, err := assets.ABMToken(ctx, s.ds, name) if err != nil { return nil, fmt.Errorf("retrieving token in nano dep storage: %w", err) } diff --git a/server/datastore/mysql/operating_system_vulnerabilities.go b/server/datastore/mysql/operating_system_vulnerabilities.go index c7598e0464..8a6a30dec0 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities.go +++ b/server/datastore/mysql/operating_system_vulnerabilities.go @@ -123,7 +123,7 @@ func (ds *Datastore) InsertOSVulnerability(ctx context.Context, v fleet.OSVulner return false, ctxerr.Wrap(ctx, err, "insert operating system vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) DeleteOSVulnerabilities(ctx context.Context, vulnerabilities []fleet.OSVulnerability) error { diff --git a/server/datastore/mysql/operating_system_vulnerabilities_test.go b/server/datastore/mysql/operating_system_vulnerabilities_test.go index 715d8eada3..0b477091cb 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities_test.go +++ b/server/datastore/mysql/operating_system_vulnerabilities_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -233,10 +234,15 @@ func testInsertOSVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, didInsert) - // Inserting the same vulnerability should not insert - didInsert, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + // Inserting the same vulnerability should not insert, but update + didInsertOrUpdate, err := ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) require.NoError(t, err) - require.Equal(t, false, didInsert) + assert.True(t, didInsertOrUpdate) + + // Inserting the exact same vulnerability again should not insert and not update + didInsertOrUpdate, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + require.NoError(t, err) + assert.False(t, didInsertOrUpdate) expected := vulnsUpdate expected.Source = fleet.MSRCSource diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 4df1f9324a..7ca49d3564 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -12,9 +12,9 @@ import ( "golang.org/x/text/unicode/norm" - "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" @@ -22,12 +22,18 @@ import ( const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, - p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled + p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, + p.calendar_events_enabled, p.software_installer_id ` +var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be set on team policies only") + var policySearchColumns = []string{"p.name"} func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { + if args.SoftwareInstallerID != nil { + return nil, ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy") + } if args.QueryID != nil { q, err := ds.Query(ctx, *args.QueryID) if err != nil { @@ -97,8 +103,7 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ((p.team_id IS NULL AND ps.inherited_team_id = 0) - OR (p.team_id IS NOT NULL AND ps.inherited_team_id = p.team_id)) + AND ((p.team_id IS NULL AND ps.inherited_team_id IS NULL) OR (p.team_id IS NOT NULL)) WHERE p.id=? AND %s`, policyCols, teamWhere), args...) if err != nil { @@ -129,15 +134,18 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite // // Currently, SavePolicy does not allow updating the team of an existing policy. func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error { + if p.TeamID == nil && p.SoftwareInstallerID != nil { + return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "save policy") + } // We must normalize the name for full Unicode support (Unicode equivalence). p.Name = norm.NFC.String(p.Name) sql := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := ds.writer(ctx).ExecContext( - ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID, + ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -372,7 +380,7 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op COALESCE(ps.failing_host_count, 0) AS failing_host_count FROM policies p LEFT JOIN users u ON p.author_id = u.id - LEFT JOIN policy_stats ps ON p.id = ps.policy_id AND ps.inherited_team_id = 0 + LEFT JOIN policy_stats ps ON p.id = ps.policy_id AND ps.inherited_team_id IS NULL ` if teamID != nil { @@ -484,12 +492,12 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl COALESCE(u.email, '') AS author_email, ps.updated_at as host_count_updated_at, COALESCE(ps.passing_host_count, 0) as passing_host_count, - COALESCE(ps.failing_host_count, 0) as failing_host_count + COALESCE(ps.failing_host_count, 0) as failing_host_count, + p.software_installer_id FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ((p.team_id IS NULL AND ps.inherited_team_id = 0) - OR (p.team_id IS NOT NULL AND ps.inherited_team_id = p.team_id)) + AND ((p.team_id IS NULL AND ps.inherited_team_id IS NULL) OR (p.team_id IS NOT NULL)) WHERE p.id IN (?)` query, args, err := sqlx.In(sql, ids) if err != nil { @@ -546,38 +554,25 @@ func deletePolicyDB(ctx context.Context, q sqlx.ExtContext, ids []uint, teamID * // PolicyQueriesForHost returns the policy queries that are to be executed on the given host. func (ds *Datastore) PolicyQueriesForHost(ctx context.Context, host *fleet.Host) (map[string]string, error) { - var rows []struct { - ID string `db:"id"` - Query string `db:"query"` - } if host.FleetPlatform() == "" { // We log to help troubleshooting in case this happens, as the host // won't be receiving any policies targeted for specific platforms. level.Error(ds.logger).Log("err", "unrecognized platform", "hostID", host.ID, "platform", host.Platform) //nolint:errcheck } - q := dialect.From("policies").Select( - goqu.I("id"), - goqu.I("query"), - ).Where( - goqu.And( - goqu.Or( - goqu.I("platforms").Eq(""), - goqu.L("FIND_IN_SET(?, ?)", - host.FleetPlatform(), - goqu.I("platforms"), - ).Neq(0), - ), - goqu.Or( - goqu.I("team_id").IsNull(), // global policies - goqu.I("team_id").Eq(host.TeamID), // team policies - ), - ), - ) - sql, args, err := q.ToSQL() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "selecting policies sql build") + const stmt = ` + SELECT id, query + FROM policies + WHERE + -- team_id == NULL are global policies that apply to all hosts + -- team_id == 0 are policies that apply to hosts in "No team" + -- team_id > 0 are policies that apply to hosts in teams + (team_id IS NULL OR team_id = COALESCE(?, 0)) AND + (platforms = '' OR FIND_IN_SET(?, platforms))` + var rows []struct { + ID string `db:"id"` + Query string `db:"query"` } - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, sql, args...); err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, host.TeamID, host.FleetPlatform()); err != nil { return nil, ctxerr.Wrap(ctx, err, "selecting policies for host") } results := make(map[string]string) @@ -597,15 +592,27 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u args.Query = q.Query args.Description = q.Description } + // Check team exists. + if teamID > 0 { + var ok bool + err := ds.writer(ctx).GetContext(ctx, &ok, `SELECT COUNT(*) = 1 FROM teams WHERE id = ?`, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get team id") + } + if !ok { + return nil, ctxerr.Wrap(ctx, notFound("Team").WithID(teamID), "get team id") + } + + } // We must normalize the name for full Unicode support (Unicode equivalence). nameUnicode := norm.NFC.String(args.Name) res, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, - args.CalendarEventsEnabled, + args.CalendarEventsEnabled, args.SoftwareInstallerID, ) switch { case err == nil: @@ -649,7 +656,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id - AND ps.inherited_team_id = IF(p.team_id IS NULL, ?, 0) + AND (p.team_id IS NOT NULL OR ps.inherited_team_id = ?) WHERE (p.team_id = ? OR p.team_id IS NULL) ` @@ -689,8 +696,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs queryerContext := ds.writer(ctx) // Preprocess specs and group them by team - teamNameToID := make(map[string]uint, 1) - teamIDToPolicies := make(map[uint][]*fleet.PolicySpec, 1) + teamNameToID := make(map[string]*uint, 1) + teamIDToPolicies := make(map[*uint][]*fleet.PolicySpec, 1) + softwareInstallerIDs := make(map[*uint]map[uint]*uint) // teamID -> titleID -> softwareInstallerID // Get the team IDs for _, spec := range specs { @@ -700,13 +708,18 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamID, ok := teamNameToID[spec.Team] if !ok { if spec.Team != "" { - // if team name is not empty, it must have a team ID; otherwise teamID defaults to 0 value - err := sqlx.GetContext(ctx, queryerContext, &teamID, `SELECT id FROM teams WHERE name = ?`, spec.Team) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ctxerr.Wrap(ctx, notFound("Team").WithName(spec.Team), "get team id") + if spec.Team == "No team" { + teamID = ptr.Uint(0) + } else { + var tmID uint + err := sqlx.GetContext(ctx, queryerContext, &tmID, `SELECT id FROM teams WHERE name = ?`, spec.Team) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("Team").WithName(spec.Team), "get team id") + } + return ctxerr.Wrap(ctx, err, "get team id") } - return ctxerr.Wrap(ctx, err, "get team id") + teamID = &tmID } } teamNameToID[spec.Team] = teamID @@ -714,13 +727,38 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamIDToPolicies[teamID] = append(teamIDToPolicies[teamID], spec) } + // Get software installer ids from software title ids. + for _, spec := range specs { + if spec.SoftwareTitleID == nil || *spec.SoftwareTitleID == 0 { + continue + } + if spec.Team == "" { + return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy from spec") + } + var softwareInstallerID uint + err := sqlx.GetContext(ctx, queryerContext, &softwareInstallerID, + `SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id = ?`, + teamNameToID[spec.Team], spec.SoftwareTitleID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("SoftwareInstaller").WithID(*spec.SoftwareTitleID), "get software installer id") + } + return ctxerr.Wrap(ctx, err, "get software installer id") + } + if len(softwareInstallerIDs[teamNameToID[spec.Team]]) == 0 { + softwareInstallerIDs[teamNameToID[spec.Team]] = make(map[uint]*uint) + } + softwareInstallerIDs[teamNameToID[spec.Team]][*spec.SoftwareTitleID] = &softwareInstallerID + } + // Get the query and platforms of the current policies so that we can check if query or platform changed later, if needed type policyLite struct { - Name string `db:"name"` - Query string `db:"query"` - Platforms string `db:"platforms"` + Name string `db:"name"` + Query string `db:"query"` + Platforms string `db:"platforms"` + SoftwareInstallerID *uint `db:"software_installer_id"` } - teamIDToPoliciesByName := make(map[uint]map[string]policyLite, len(teamIDToPolicies)) + teamIDToPoliciesByName := make(map[*uint]map[string]policyLite, len(teamIDToPolicies)) for teamID, teamPolicySpecs := range teamIDToPolicies { teamIDToPoliciesByName[teamID] = make(map[string]policyLite, len(teamPolicySpecs)) policyNames := make([]string, 0, len(teamPolicySpecs)) @@ -731,11 +769,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs var query string var args []interface{} var err error - if teamID == 0 { - query, args, err = sqlx.In("SELECT name, query, platforms FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) + if teamID == nil { + query, args, err = sqlx.In("SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) } else { query, args, err = sqlx.In( - "SELECT name, query, platforms FROM policies WHERE team_id = ? AND name IN (?)", &teamID, policyNames, + "SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, ) } if err != nil { @@ -764,8 +802,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs platforms, critical, calendar_events_enabled, + software_installer_id, checksum - ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), @@ -773,26 +812,26 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs resolution = VALUES(resolution), platforms = VALUES(platforms), critical = VALUES(critical), - calendar_events_enabled = VALUES(calendar_events_enabled) + calendar_events_enabled = VALUES(calendar_events_enabled), + software_installer_id = VALUES(software_installer_id) `, policiesChecksumComputedColumn(), ) for teamID, teamPolicySpecs := range teamIDToPolicies { - var teamIDPtr *uint - if teamID != 0 { - teamIDPtr = &teamID - } for _, spec := range teamPolicySpecs { - + var softwareInstallerID *uint + if spec.SoftwareTitleID != nil { + softwareInstallerID = softwareInstallerIDs[teamNameToID[spec.Team]][*spec.SoftwareTitleID] + } res, err := tx.ExecContext( ctx, - query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamIDPtr, spec.Platform, spec.Critical, - spec.CalendarEventsEnabled, + query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical, + spec.CalendarEventsEnabled, softwareInstallerID, ) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") } - if insertOnDuplicateDidUpdate(res) { + if insertOnDuplicateDidInsertOrUpdate(res) { // when the upsert results in an UPDATE that *did* change some values, // it returns the updated ID as last inserted id. if lastID, _ := res.LastInsertId(); lastID > 0 { @@ -800,12 +839,21 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs shouldRemoveAllPolicyMemberships bool removePolicyStats bool ) - // Figure out if the query or platform changed + // Figure out if the query, platform or software installer changed. + var softwareInstallerID *uint + if spec.SoftwareTitleID != nil { + softwareInstallerID = softwareInstallerIDs[teamID][*spec.SoftwareTitleID] + } if prev, ok := teamIDToPoliciesByName[teamID][spec.Name]; ok { switch { case prev.Query != spec.Query: shouldRemoveAllPolicyMemberships = true removePolicyStats = true + case teamID != nil && + ((prev.SoftwareInstallerID == nil && spec.SoftwareTitleID != nil) || + (prev.SoftwareInstallerID != nil && softwareInstallerID != nil && *prev.SoftwareInstallerID != *softwareInstallerID)): + shouldRemoveAllPolicyMemberships = true + removePolicyStats = true case prev.Platforms != spec.Platform: removePolicyStats = true } @@ -1368,7 +1416,7 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { WHERE p.team_id IS NULL AND p.id = ? GROUP BY t.id, p.id` err = sqlx.SelectContext(ctx, db, &policyStats, selectStmt, policy.ID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { + if err != nil { if errors.Is(err, sql.ErrNoRows) { // Policy or team was deleted by a parallel process. We proceed. level.Error(ds.logger).Log( @@ -1378,6 +1426,38 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { } return ctxerr.Wrap(ctx, err, "select policy counts for inherited global policies") } + + noTeamStmt := `SELECT + p.id as policy_id, + 0 AS inherited_team_id, -- 0 means "No team" + ( + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id + WHERE pm.policy_id = p.id AND pm.passes = true AND h.team_id IS NULL + ) AS passing_host_count, + ( + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id + WHERE pm.policy_id = p.id AND pm.passes = false AND h.team_id IS NULL + ) AS failing_host_count + FROM policies p + WHERE p.team_id IS NULL AND p.id = ?` + var noTeamPolicyStats []policyStat + err = sqlx.SelectContext(ctx, db, &noTeamPolicyStats, noTeamStmt, policy.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // Policy was deleted by a parallel process. We proceed. + level.Error(ds.logger).Log( + "msg", "'No team' policy not found for inherited global policies. Was policy deleted?", "policy_id", policy.ID, + ) + continue + } + return ctxerr.Wrap(ctx, err, "select policy counts for inherited global policies for 'no team' policies") + } + policyStats = append(policyStats, noTeamPolicyStats...) + insertStmt := `INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) VALUES (:policy_id, :inherited_team_id, :passing_host_count, :failing_host_count) ON DUPLICATE KEY UPDATE @@ -1401,7 +1481,7 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { INSERT INTO policy_stats (policy_id, inherited_team_id, passing_host_count, failing_host_count) SELECT p.id, - 0 AS inherited_team_id, -- using 0 to represent global scope + NULL AS inherited_team_id, -- using NULL to represent global scope COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0), COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 0)), 0) FROM policies p @@ -1429,6 +1509,22 @@ func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fl return policies, nil } +func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + if len(policyIDs) == 0 { + return nil, nil + } + query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);` + query, args, err := sqlx.In(query, teamID, policyIDs) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer") + } + var policies []fleet.PolicySoftwareInstallerData + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies with associated installer") + } + return policies, nil +} + func (ds *Datastore) GetTeamHostsPolicyMemberships( ctx context.Context, domain string, diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index d3b9bdb93a..96392e494f 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -1,6 +1,7 @@ package mysql import ( + "bytes" "context" "crypto/md5" //nolint:gosec // (only used for tests) "encoding/hex" @@ -63,6 +64,10 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNameSort", testPoliciesNameSort}, {"TestGetCalendarPolicies", testGetCalendarPolicies}, {"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships}, + {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, + {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, + {"ApplyPolicySpecWithInstallers", testApplyPolicySpecWithInstallers}, + {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1219,9 +1224,29 @@ func testPolicyQueriesForHost(t *testing.T, ds *Datastore) { func testPoliciesByID(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) policy1 := newTestPolicy(t, ds, user1, "policy1", "darwin", nil) - _ = newTestPolicy(t, ds, user1, "policy2", "darwin", nil) + team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + policy2 := newTestPolicy(t, ds, user1, "policy2", "darwin", &team1.ID) host1 := newTestHostWithPlatform(t, ds, "host1", "darwin", nil) + // Associate an installer to policy2 + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), host1, map[uint]*bool{policy1.ID: ptr.Bool(true)}, time.Now(), false)) require.NoError(t, ds.UpdateHostPolicyCounts(context.Background())) @@ -1230,9 +1255,12 @@ func testPoliciesByID(t *testing.T, ds *Datastore) { assert.Equal(t, len(policiesByID), 2) assert.Equal(t, policiesByID[1].ID, policy1.ID) assert.Equal(t, policiesByID[1].Name, policy1.Name) + assert.Nil(t, policiesByID[1].SoftwareInstallerID) + assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) assert.Equal(t, policiesByID[2].ID, uint(2)) assert.Equal(t, policiesByID[2].Name, "policy2") - assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) + assert.NotNil(t, policiesByID[2].SoftwareInstallerID) + assert.Equal(t, uint(1), *policiesByID[2].SoftwareInstallerID) _, err = ds.PoliciesByID(context.Background(), []uint{1, 2, 3}) require.Error(t, err) @@ -1386,6 +1414,14 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Team: "team1", Platform: "windows,linux", }, + { + Name: "query4", + Query: "select 4;", + Description: "query4 desc", + Resolution: "some other good resolution 2", + Team: "No team", + Platform: "", + }, })) policies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) @@ -1423,6 +1459,21 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { assert.Equal(t, "windows,linux", teamPolicies[1].Platform) assert.False(t, teamPolicies[1].CalendarEventsEnabled) + noTeamPolicies, _, err := ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) + assert.Equal(t, "query4", noTeamPolicies[0].Name) + assert.Equal(t, "select 4;", noTeamPolicies[0].Query) + assert.Equal(t, "query4 desc", noTeamPolicies[0].Description) + require.NotNil(t, noTeamPolicies[0].AuthorID) + assert.Equal(t, user1.ID, *noTeamPolicies[0].AuthorID) + require.NotNil(t, noTeamPolicies[0].Resolution) + assert.Equal(t, "some other good resolution 2", *noTeamPolicies[0].Resolution) + assert.Equal(t, "", noTeamPolicies[0].Platform) + assert.False(t, noTeamPolicies[0].CalendarEventsEnabled) + assert.NotNil(t, noTeamPolicies[0].TeamID) + assert.Zero(t, *noTeamPolicies[0].TeamID) + // Make sure apply is idempotent require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ { @@ -1450,6 +1501,14 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Team: "team1", Platform: "windows,linux", }, + { + Name: "query4", + Query: "select 4;", + Description: "query4 desc", + Resolution: "some other good resolution 2", + Team: "No team", + Platform: "", + }, })) policies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) @@ -1458,6 +1517,9 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { teamPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) require.NoError(t, err) require.Len(t, teamPolicies, 2) + noTeamPolicies, _, err = ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) // Test policy updating. require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -3875,3 +3937,894 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) } + +func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) { + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{ + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(1), + }) + require.Error(t, err) + require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) +} + +func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) // team2 has no policies + require.NoError(t, err) + + // Policy p1 has no associated installer. + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: nil, + }) + require.NoError(t, err) + // Create and associate an installer to p2. + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + require.Nil(t, p1.SoftwareInstallerID) + p2, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p2", + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + // Create p3 as global policy. + _, err = ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "p3", + Query: "SELECT 1;", + }) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + // Policy p4 in "No team" with associated installer. + p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "p4", + Query: "SELECT 4;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.NoError(t, err) + policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, fleet.PolicyNoTeamID, []uint{p4.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 1) + require.Equal(t, p4.ID, policiesWithInstallers[0].ID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + // p1 has no associated installers. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 1) + require.Equal(t, p2.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + + // p2 has associated installer but belongs to team1. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + p1.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(ctx, p1, false, false) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 2) + require.Equal(t, p1.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + require.Equal(t, p2.ID, policiesWithInstallers[1].ID) + require.Equal(t, installerID, policiesWithInstallers[1].InstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) +} + +func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "User1", "user1@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: name, + TeamID: teamID, + Platform: platform, + }) + require.NoError(t, err) + return h + } + + host1Team1 := newHost("host1Team1", &team1.ID, "darwin") + + installer1ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1;", + PostInstallScript: "world1", + InstallerFile: bytes.NewReader([]byte("hello1")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + }) + require.NoError(t, err) + installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID) + require.NoError(t, err) + require.NotNil(t, installer1.TitleID) + installer2ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello2", + PreInstallQuery: "SELECT 2;", + PostInstallScript: "world2", + InstallerFile: bytes.NewReader([]byte("hello2")), + StorageID: "storage2", + Filename: "file2", + Title: "file2", + Version: "1.0", + Source: "deb_packages", + UserID: user1.ID, + TeamID: &team2.ID, + }) + require.NoError(t, err) + installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID) + require.NoError(t, err) + require.NotNil(t, installer2.TitleID) + installer3ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello3", + PreInstallQuery: "SELECT 3;", + PostInstallScript: "world3", + InstallerFile: bytes.NewReader([]byte("hello3")), + StorageID: "storage3", + Filename: "file3", + Title: "file3", + Version: "1.0", + Source: "rpm_packages", + UserID: user1.ID, + TeamID: nil, + }) + require.NoError(t, err) + installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) + require.NoError(t, err) + require.NotNil(t, installer3.TitleID) + // Another installer on team1 to test changing installers. + installer5ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello5", + PreInstallQuery: "SELECT 5;", + PostInstallScript: "world5", + InstallerFile: bytes.NewReader([]byte("hello5")), + StorageID: "storage5", + Filename: "file5", + Title: "file5", + Version: "1.0", + Source: "programs", + UserID: user1.ID, + TeamID: &team1.ID, + }) + require.NoError(t, err) + installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID) + require.NoError(t, err) + require.NotNil(t, installer5.TitleID) + + // Installers cannot be assigned to global policies. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Global policy", + Query: "SELECT 1;", + Description: "Description", + Resolution: "Resolution", + Team: "", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + }) + require.Error(t, err) + require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) + + // Apply two team policies associated to two installers and a "No team" policy associated to an installer. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: installer2.TitleID, + }, + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "linux", + SoftwareTitleID: installer3.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.NotNil(t, team1Policies[0].SoftwareInstallerID) + policy1Team1 := team1Policies[0] + require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) + team2Policies, _, err := ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.NotNil(t, team2Policies[0].SoftwareInstallerID) + require.Equal(t, installer2.InstallerID, *team2Policies[0].SoftwareInstallerID) + noTeamPolicies, _, err := ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, noTeamPolicies, 1) + require.NotNil(t, noTeamPolicies[0].SoftwareInstallerID) + require.Equal(t, installer3.InstallerID, *noTeamPolicies[0].SoftwareInstallerID) + + // Record policy execution on policy1Team1. + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + + // Unset software installer from "Team policy 1". + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: nil, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Nil(t, team1Policies[0].SoftwareInstallerID) + // Should not clear results because we've cleared not changed/set-new installer. + require.Equal(t, uint(1), team1Policies[0].FailingHostCount) + + // Set "Team policy 1" to a software installer on team2. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer2.TitleID, + }, + }) + require.Error(t, err) + var notFoundErr *notFoundError + require.ErrorAs(t, err, ¬FoundErr) + + // Set "No team policy 3" to a software installer on team2. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "darwin", + SoftwareTitleID: installer2.TitleID, + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + + // Set "Team policy 1" to a software title that doesn't exist. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: ptr.Uint(999_999), + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + + // Set "No team policy 3" to a software title that doesn't exist. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "No team policy 3", + Query: "SELECT 3;", + Description: "Description 3", + Resolution: "Resolution 3", + Team: "No team", + Platform: "darwin", + SoftwareTitleID: ptr.Uint(999_999), + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + + // Unset software installer from "Team policy 2" using 0. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: ptr.Uint(0), + }, + }) + require.NoError(t, err) + team2Policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.Nil(t, team2Policies[0].SoftwareInstallerID) + + // Apply team policies associated to two installers (again, with two installers with the same title). + installer4ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello3", + PreInstallQuery: "SELECT 3;", + PostInstallScript: "world3", + InstallerFile: bytes.NewReader([]byte("hello3")), + StorageID: "storage3", + Filename: "file1", + Title: "file1", // same title as installer1. + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team2.ID, + }) + require.NoError(t, err) + installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID) + require.NoError(t, err) + require.NotNil(t, installer2.TitleID) + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: installer4.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.NotNil(t, team1Policies[0].SoftwareInstallerID) + require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) + // Should clear results because we've are setting an installer. + require.Equal(t, uint(0), team1Policies[0].FailingHostCount) + countBiggerThanZero := true + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.False(t, countBiggerThanZero) + team2Policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.NotNil(t, team2Policies[0].SoftwareInstallerID) + require.Equal(t, installer4.InstallerID, *team2Policies[0].SoftwareInstallerID) + + // Record policy execution on policy1Team1 to test that setting the same installer won't clear results. + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Equal(t, uint(1), team1Policies[0].FailingHostCount) + countBiggerThanZero = false + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.True(t, countBiggerThanZero) + + // Now change the installer, should clear results. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer5.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Equal(t, uint(0), team1Policies[0].FailingHostCount) + countBiggerThanZero = true + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + team1Policies[0].ID, + ) + }) + require.False(t, countBiggerThanZero) +} + +func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: name, + TeamID: teamID, + Platform: platform, + }) + require.NoError(t, err) + return h + } + + host0NoTeam := newHost("host0NoTeam", nil, "darwin") + host1Team1 := newHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newHost("host2Team1", &team1.ID, "linux") + host3Team2 := newHost("host1Team1", &team2.ID, "windows") + host5NoTeam := newHost("host5NoTeam", nil, "windows") + + policy0NoTeam, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "policy0NoTeam", + Query: "SELECT 0;", + }) + require.NoError(t, err) + require.NotNil(t, policy0NoTeam.TeamID) + require.Equal(t, fleet.PolicyNoTeamID, *policy0NoTeam.TeamID) + tp, err := ds.TeamPolicy(ctx, fleet.PolicyNoTeamID, policy0NoTeam.ID) + require.NoError(t, err) + require.Equal(t, tp, policy0NoTeam) + + policy1Team1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + }) + require.NoError(t, err) + policy2Team2, err := ds.NewTeamPolicy(ctx, team2.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy2Team2", + Query: "SELECT 2;", + }) + require.NoError(t, err) + policy3NoTeam, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "policy3NoTeam", + Query: "SELECT 3;", + }) + require.NoError(t, err) + policy4Team2, err := ds.NewTeamPolicy(ctx, team2.ID, &user1.ID, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + }) + require.NoError(t, err) + + globalPolicy1, err := ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "globalPolicy1", + Query: "SELECT gp1;", + }) + require.NoError(t, err) + globalPolicy2, err := ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "globalPolicy2", + Query: "SELECT gp2;", + }) + require.NoError(t, err) + + // Results for host0NoTeam + err = ds.RecordPolicyQueryExecutions(ctx, host0NoTeam, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(false), + globalPolicy2.ID: ptr.Bool(false), + policy0NoTeam.ID: ptr.Bool(true), + policy3NoTeam.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host1Team1 + err = ds.RecordPolicyQueryExecutions(ctx, host1Team1, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + globalPolicy2.ID: nil, // failed to execute, e.g. typo on SQL. + policy1Team1.ID: ptr.Bool(true), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host2Team1 + err = ds.RecordPolicyQueryExecutions(ctx, host2Team1, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(false), + globalPolicy2.ID: ptr.Bool(true), + policy1Team1.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host3Team2 + err = ds.RecordPolicyQueryExecutions(ctx, host3Team2, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + policy2Team2.ID: ptr.Bool(true), + policy4Team2.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + // Results for host5NoTeam + err = ds.RecordPolicyQueryExecutions(ctx, host5NoTeam, map[uint]*bool{ + globalPolicy1.ID: ptr.Bool(true), + globalPolicy2.ID: ptr.Bool(false), + policy0NoTeam.ID: ptr.Bool(false), + policy3NoTeam.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + + // Tests on global domain. + globalPolicies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, globalPolicies, 2) + require.Equal(t, globalPolicy1.ID, globalPolicies[0].ID) + require.Equal(t, uint(2), globalPolicies[0].FailingHostCount) + require.Equal(t, uint(3), globalPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, globalPolicies[1].ID) + require.Equal(t, uint(2), globalPolicies[1].FailingHostCount) + require.Equal(t, uint(1), globalPolicies[1].PassingHostCount) + ids := make([]uint, 0, len(globalPolicies)) + for _, globalPolicy := range globalPolicies { + p, err := ds.Policy(ctx, globalPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, globalPolicy) + ids = append(ids, globalPolicy.ID) + } + c, err := ds.CountPolicies(ctx, nil, "") + require.NoError(t, err) + require.Equal(t, 2, c) + globalPoliciesByID, err := ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, globalPoliciesByID, 2) + require.Equal(t, globalPoliciesByID[globalPolicies[0].ID], globalPolicies[0]) + require.Equal(t, globalPoliciesByID[globalPolicies[1].ID], globalPolicies[1]) + + // Tests on team1 domain. + teamPolicies, inheritedPolicies, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 1) + require.Equal(t, policy1Team1.ID, teamPolicies[0].ID) + require.Equal(t, uint(1), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(1), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(0), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err := ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 1) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + c, err = ds.CountMergedTeamPolicies(ctx, team1.ID, "") + require.NoError(t, err) + require.Equal(t, 3, c) + c, err = ds.CountPolicies(ctx, &team1.ID, "") + require.NoError(t, err) + require.Equal(t, 1, c) + mergedTeamPolicies, err := ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 3) + require.Equal(t, policy1Team1.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(1), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(1), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(0), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + + // Tests on team2 domain. + teamPolicies, inheritedPolicies, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 2) + require.Equal(t, policy2Team2.ID, teamPolicies[0].ID) + require.Equal(t, uint(0), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Equal(t, policy4Team2.ID, teamPolicies[1].ID) + require.Equal(t, uint(1), teamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), teamPolicies[1].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(0), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(0), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(0), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err = ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 2) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) + c, err = ds.CountMergedTeamPolicies(ctx, team2.ID, "") + require.NoError(t, err) + require.Equal(t, 4, c) + c, err = ds.CountPolicies(ctx, &team2.ID, "") + require.NoError(t, err) + require.Equal(t, 2, c) + mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, team2.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 4) + require.Equal(t, policy2Team2.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(0), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, policy4Team2.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(1), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(0), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[3].ID) + require.Equal(t, uint(0), mergedTeamPolicies[3].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[3].PassingHostCount) + + // Tests on "No team" domain. + teamPolicies, inheritedPolicies, err = ds.ListTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teamPolicies, 2) + require.Equal(t, policy0NoTeam.ID, teamPolicies[0].ID) + require.Equal(t, uint(1), teamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), teamPolicies[0].PassingHostCount) + require.Equal(t, policy3NoTeam.ID, teamPolicies[1].ID) + require.Equal(t, uint(2), teamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), teamPolicies[1].PassingHostCount) + require.Len(t, inheritedPolicies, 2) + require.Equal(t, globalPolicy1.ID, inheritedPolicies[0].ID) + require.Equal(t, uint(1), inheritedPolicies[0].FailingHostCount) + require.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) + require.Equal(t, globalPolicy2.ID, inheritedPolicies[1].ID) + require.Equal(t, uint(2), inheritedPolicies[1].FailingHostCount) + require.Equal(t, uint(0), inheritedPolicies[1].PassingHostCount) + ids = make([]uint, 0, len(teamPolicies)) + for _, teamPolicy := range teamPolicies { + p, err := ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, p, teamPolicy) + ids = append(ids, teamPolicy.ID) + } + teamPoliciesByID, err = ds.PoliciesByID(ctx, ids) + require.NoError(t, err) + require.Len(t, teamPoliciesByID, 2) + require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) + require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) + c, err = ds.CountMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, "") + require.NoError(t, err) + require.Equal(t, 4, c) + c, err = ds.CountPolicies(ctx, ptr.Uint(fleet.PolicyNoTeamID), "") + require.NoError(t, err) + require.Equal(t, 2, c) + mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, mergedTeamPolicies, 4) + require.Equal(t, policy0NoTeam.ID, mergedTeamPolicies[0].ID) + require.Equal(t, uint(1), mergedTeamPolicies[0].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[0].PassingHostCount) + require.Equal(t, policy3NoTeam.ID, mergedTeamPolicies[1].ID) + require.Equal(t, uint(2), mergedTeamPolicies[1].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[1].PassingHostCount) + require.Equal(t, globalPolicy1.ID, mergedTeamPolicies[2].ID) + require.Equal(t, uint(1), mergedTeamPolicies[2].FailingHostCount) + require.Equal(t, uint(1), mergedTeamPolicies[2].PassingHostCount) + require.Equal(t, globalPolicy2.ID, mergedTeamPolicies[3].ID) + require.Equal(t, uint(2), mergedTeamPolicies[3].FailingHostCount) + require.Equal(t, uint(0), mergedTeamPolicies[3].PassingHostCount) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host0NoTeam. + host0Policies, err := ds.ListPoliciesForHost(ctx, host0NoTeam) + require.NoError(t, err) + require.Len(t, host0Policies, 4) + require.Equal(t, globalPolicy1.ID, host0Policies[0].ID) + require.Equal(t, "fail", host0Policies[0].Response) + require.Equal(t, globalPolicy2.ID, host0Policies[1].ID) + require.Equal(t, "fail", host0Policies[1].Response) + require.Equal(t, policy3NoTeam.ID, host0Policies[2].ID) + require.Equal(t, "fail", host0Policies[2].Response) + require.Equal(t, policy0NoTeam.ID, host0Policies[3].ID) + require.Equal(t, "pass", host0Policies[3].Response) + host0PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host0NoTeam) + require.NoError(t, err) + require.Len(t, host0PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host0PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host0PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 0;", host0PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)]) + require.Equal(t, "SELECT 3;", host0PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host1Team1. + host1Policies, err := ds.ListPoliciesForHost(ctx, host1Team1) + require.NoError(t, err) + require.Len(t, host1Policies, 3) + require.Equal(t, globalPolicy2.ID, host1Policies[0].ID) + require.Equal(t, "", host1Policies[0].Response) + require.Equal(t, globalPolicy1.ID, host1Policies[1].ID) + require.Equal(t, "pass", host1Policies[1].Response) + require.Equal(t, policy1Team1.ID, host1Policies[2].ID) + require.Equal(t, "pass", host1Policies[2].Response) + host1PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host1Team1) + require.NoError(t, err) + require.Len(t, host1PolicyQueries, 3) + require.Equal(t, "SELECT gp1;", host1PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host1PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 1;", host1PolicyQueries[strconv.FormatUint(uint64(policy1Team1.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host2Team1. + host2Policies, err := ds.ListPoliciesForHost(ctx, host2Team1) + require.NoError(t, err) + require.Len(t, host2Policies, 3) + require.Equal(t, globalPolicy1.ID, host2Policies[0].ID) + require.Equal(t, "fail", host2Policies[0].Response) + require.Equal(t, policy1Team1.ID, host2Policies[1].ID) + require.Equal(t, "fail", host2Policies[1].Response) + require.Equal(t, globalPolicy2.ID, host2Policies[2].ID) + require.Equal(t, "pass", host2Policies[2].Response) + host2PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host2Team1) + require.NoError(t, err) + require.Len(t, host2PolicyQueries, 3) + require.Equal(t, "SELECT gp1;", host2PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host2PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 1;", host2PolicyQueries[strconv.FormatUint(uint64(policy1Team1.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host3Team2. + host3Policies, err := ds.ListPoliciesForHost(ctx, host3Team2) + require.NoError(t, err) + require.Len(t, host3Policies, 4) + require.Equal(t, policy4Team2.ID, host3Policies[0].ID) + require.Equal(t, "fail", host3Policies[0].Response) + require.Equal(t, globalPolicy2.ID, host3Policies[1].ID) + require.Equal(t, "", host3Policies[1].Response) + require.Equal(t, globalPolicy1.ID, host3Policies[2].ID) + require.Equal(t, "pass", host3Policies[2].Response) + require.Equal(t, policy2Team2.ID, host3Policies[3].ID) + require.Equal(t, "pass", host3Policies[3].Response) + host3PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host3Team2) + require.NoError(t, err) + require.Len(t, host3PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host3PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host3PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 2;", host3PolicyQueries[strconv.FormatUint(uint64(policy2Team2.ID), 10)]) + require.Equal(t, "SELECT 4;", host3PolicyQueries[strconv.FormatUint(uint64(policy4Team2.ID), 10)]) + + // Test ListPoliciesForHost and PolicyQueriesForHost for host5NoTeam. + host5Policies, err := ds.ListPoliciesForHost(ctx, host5NoTeam) + require.NoError(t, err) + require.Len(t, host5Policies, 4) + require.Equal(t, globalPolicy2.ID, host5Policies[0].ID) + require.Equal(t, "fail", host5Policies[0].Response) + require.Equal(t, policy0NoTeam.ID, host5Policies[1].ID) + require.Equal(t, "fail", host5Policies[1].Response) + require.Equal(t, policy3NoTeam.ID, host5Policies[2].ID) + require.Equal(t, "fail", host5Policies[2].Response) + require.Equal(t, globalPolicy1.ID, host5Policies[3].ID) + require.Equal(t, "pass", host5Policies[3].Response) + host5PolicyQueries, err := ds.PolicyQueriesForHost(ctx, host5NoTeam) + require.NoError(t, err) + require.Len(t, host5PolicyQueries, 4) + require.Equal(t, "SELECT gp1;", host5PolicyQueries[strconv.FormatUint(uint64(globalPolicy1.ID), 10)]) + require.Equal(t, "SELECT gp2;", host5PolicyQueries[strconv.FormatUint(uint64(globalPolicy2.ID), 10)]) + require.Equal(t, "SELECT 0;", host5PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)]) + require.Equal(t, "SELECT 3;", host5PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)]) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 73e02ac322..8fb66675fb 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1,5 +1,29 @@ /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `abm_tokens` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `organization_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `apple_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `terms_expired` tinyint(1) NOT NULL DEFAULT '0', + `renew_at` timestamp NOT NULL, + `token` blob NOT NULL, + `macos_default_team_id` int unsigned DEFAULT NULL, + `ios_default_team_id` int unsigned DEFAULT NULL, + `ipados_default_team_id` int unsigned DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_abm_tokens_organization_name` (`organization_name`), + KEY `fk_abm_tokens_macos_default_team_id` (`macos_default_team_id`), + KEY `fk_abm_tokens_ios_default_team_id` (`ios_default_team_id`), + KEY `fk_abm_tokens_ipados_default_team_id` (`ipados_default_team_id`), + CONSTRAINT `fk_abm_tokens_ios_default_team_id` FOREIGN KEY (`ios_default_team_id`) REFERENCES `teams` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_abm_tokens_ipados_default_team_id` FOREIGN KEY (`ipados_default_team_id`) REFERENCES `teams` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_abm_tokens_macos_default_team_id` FOREIGN KEY (`macos_default_team_id`) REFERENCES `teams` (`id`) ON DELETE SET NULL +) 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 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `activities` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), @@ -41,7 +65,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"volume_purchasing_program\": null, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `calendar_events` ( @@ -233,8 +257,11 @@ CREATE TABLE `host_dep_assignments` ( `assign_profile_response` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `response_updated_at` timestamp NULL DEFAULT NULL, `retry_job_id` int unsigned NOT NULL DEFAULT '0', + `abm_token_id` int unsigned DEFAULT NULL, PRIMARY KEY (`host_id`), - KEY `idx_hdep_response` (`assign_profile_response`,`response_updated_at`) + KEY `idx_hdep_response` (`assign_profile_response`,`response_updated_at`), + KEY `fk_host_dep_assignments_abm_token_id` (`abm_token_id`), + CONSTRAINT `fk_host_dep_assignments_abm_token_id` FOREIGN KEY (`abm_token_id`) REFERENCES `abm_tokens` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ 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 */; @@ -389,6 +416,16 @@ CREATE TABLE `host_mdm_apple_profiles` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `host_mdm_commands` ( + `host_id` int unsigned NOT NULL, + `command_type` varchar(31) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`host_id`,`command_type`) +) 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 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `host_mdm_windows_profiles` ( `host_uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -516,10 +553,15 @@ CREATE TABLE `host_software_installs` ( `post_install_script_output` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, `post_install_script_exit_code` int DEFAULT NULL, `user_id` int unsigned DEFAULT NULL, - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `self_service` tinyint(1) NOT NULL DEFAULT '0', - `host_deleted_at` timestamp NULL DEFAULT NULL, + `host_deleted_at` timestamp(6) NULL DEFAULT NULL, + `removed` tinyint NOT NULL DEFAULT '0', + `uninstall_script_output` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, + `uninstall_script_exit_code` int DEFAULT NULL, + `uninstall` tinyint unsigned NOT NULL DEFAULT '0', + `status` enum('pending_install','failed_install','installed','pending_uninstall','failed_uninstall') COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS ((case when (`removed` = 1) then NULL when ((`post_install_script_exit_code` is not null) and (`post_install_script_exit_code` = 0)) then _utf8mb4'installed' when ((`post_install_script_exit_code` is not null) and (`post_install_script_exit_code` <> 0)) then _utf8mb4'failed_install' when ((`install_script_exit_code` is not null) and (`install_script_exit_code` = 0)) then _utf8mb4'installed' when ((`install_script_exit_code` is not null) and (`install_script_exit_code` <> 0)) then _utf8mb4'failed_install' when ((`pre_install_query_output` is not null) and (`pre_install_query_output` = _utf8mb4'')) then _utf8mb4'failed_install' when ((`host_id` is not null) and (`uninstall` = 0)) then _utf8mb4'pending_install' when ((`uninstall_script_exit_code` is not null) and (`uninstall_script_exit_code` <> 0)) then _utf8mb4'failed_uninstall' when ((`uninstall_script_exit_code` is not null) and (`uninstall_script_exit_code` = 0)) then NULL when ((`host_id` is not null) and (`uninstall` = 1)) then _utf8mb4'pending_uninstall' else NULL end)) STORED, PRIMARY KEY (`id`), UNIQUE KEY `idx_host_software_installs_execution_id` (`execution_id`), KEY `fk_host_software_installs_installer_id` (`software_installer_id`), @@ -565,10 +607,14 @@ CREATE TABLE `host_vpp_software_installs` ( `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `removed` tinyint NOT NULL DEFAULT '0', + `vpp_token_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_host_vpp_software_installs_command_uuid` (`command_uuid`), KEY `user_id` (`user_id`), KEY `adam_id` (`adam_id`,`platform`), + KEY `fk_host_vpp_software_installs_vpp_token_id` (`vpp_token_id`), + CONSTRAINT `fk_host_vpp_software_installs_vpp_token_id` FOREIGN KEY (`vpp_token_id`) REFERENCES `vpp_tokens` (`id`) ON DELETE SET NULL, CONSTRAINT `host_vpp_software_installs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, CONSTRAINT `host_vpp_software_installs_ibfk_3` FOREIGN KEY (`adam_id`, `platform`) REFERENCES `vpp_apps` (`adam_id`, `platform`) ON DELETE CASCADE ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -623,8 +669,7 @@ CREATE TABLE `hosts` ( KEY `fk_hosts_team_id` (`team_id`), KEY `hosts_platform_idx` (`platform`), KEY `idx_hosts_hardware_serial` (`hardware_serial`), - FULLTEXT KEY `host_ip_mac_search` (`primary_ip`,`primary_mac`), - FULLTEXT KEY `hosts_search` (`hostname`,`uuid`,`computer_name`), + KEY `idx_hosts_uuid` (`uuid`), CONSTRAINT `hosts_ibfk_1` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -670,7 +715,8 @@ CREATE TABLE `jobs` ( `retries` int NOT NULL DEFAULT '0', `error` text COLLATE utf8mb4_unicode_ci, `not_before` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + KEY `idx_jobs_state_not_before_updated_at` (`state`,`not_before`,`updated_at`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; INSERT INTO `jobs` VALUES (1,'2024-03-20 00:00:00','2024-03-20 00:00:00','macos_setup_assistant','{\"task\": \"update_all_profiles\"}','queued',0,'','2024-03-20 00:00:00'); @@ -768,10 +814,12 @@ CREATE TABLE `mdm_apple_declarations` ( `checksum` binary(16) NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`declaration_uuid`), UNIQUE KEY `idx_mdm_apple_declaration_team_identifier` (`team_id`,`identifier`), - UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) +) /*!50100 TABLESPACE `innodb_system` */ 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 */; /*!50503 SET character_set_client = utf8mb4 */; @@ -795,11 +843,14 @@ CREATE TABLE `mdm_apple_default_setup_assistants` ( `profile_uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `abm_token_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_mdm_default_setup_assistant_global_or_team_id` (`global_or_team_id`), + UNIQUE KEY `idx_mdm_default_setup_assistant_global_or_team_id_abm_token_id` (`global_or_team_id`,`abm_token_id`), KEY `fk_mdm_default_setup_assistant_team_id` (`team_id`), + KEY `fk_mdm_default_setup_assistant_abm_token_id` (`abm_token_id`), + CONSTRAINT `fk_mdm_default_setup_assistant_abm_token_id` FOREIGN KEY (`abm_token_id`) REFERENCES `abm_tokens` (`id`) ON DELETE CASCADE, CONSTRAINT `mdm_apple_default_setup_assistants_ibfk_1` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ 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 */; /*!50503 SET character_set_client = utf8mb4 */; @@ -829,6 +880,22 @@ CREATE TABLE `mdm_apple_installers` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `mdm_apple_setup_assistant_profiles` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `setup_assistant_id` int unsigned NOT NULL, + `abm_token_id` int unsigned NOT NULL, + `profile_uuid` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_mdm_apple_setup_assistant_profiles_asst_id_tok_id` (`setup_assistant_id`,`abm_token_id`), + KEY `fk_mdm_apple_setup_assistant_profiles_abm_token_id` (`abm_token_id`), + CONSTRAINT `fk_mdm_apple_setup_assistant_profiles_abm_token_id` FOREIGN KEY (`abm_token_id`) REFERENCES `abm_tokens` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mdm_apple_setup_assistant_profiles_setup_assistant_id` FOREIGN KEY (`setup_assistant_id`) REFERENCES `mdm_apple_setup_assistants` (`id`) ON DELETE CASCADE +) 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 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mdm_apple_setup_assistants` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `team_id` int unsigned DEFAULT NULL, @@ -837,7 +904,6 @@ CREATE TABLE `mdm_apple_setup_assistants` ( `profile` json NOT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `profile_uuid` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `idx_mdm_setup_assistant_global_or_team_id` (`global_or_team_id`), KEY `fk_mdm_setup_assistant_team_id` (`team_id`), @@ -934,8 +1000,10 @@ CREATE TABLE `mdm_windows_configuration_profiles` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, `profile_uuid` varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`profile_uuid`), - UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`) + UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) ) /*!50100 TABLESPACE `innodb_system` */ 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 */; @@ -970,9 +1038,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=294 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=313 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240729120947,1,'2020-01-01 01:01:01'),(289,20240730171504,1,'2020-01-01 01:01:01'),(290,20240730174056,1,'2020-01-01 01:01:01'),(291,20240730215453,1,'2020-01-01 01:01:01'),(292,20240730374423,1,'2020-01-01 01:01:01'),(293,20240801115359,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1171,6 +1239,7 @@ CREATE TABLE `nano_users` ( `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`,`device_id`), + UNIQUE KEY `idx_unique_id` (`id`), KEY `device_id` (`device_id`), CONSTRAINT `nano_users_ibfk_1` FOREIGN KEY (`device_id`) REFERENCES `nano_devices` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT `nano_users_chk_1` CHECK (((`user_short_name` is null) or (`user_short_name` <> _utf8mb4''))), @@ -1319,11 +1388,13 @@ CREATE TABLE `policies` ( `critical` tinyint(1) NOT NULL DEFAULT '0', `checksum` binary(16) NOT NULL, `calendar_events_enabled` tinyint unsigned NOT NULL DEFAULT '0', + `software_installer_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), KEY `idx_policies_team_id` (`team_id`), - CONSTRAINT `policies_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + KEY `fk_policies_software_installer_id` (`software_installer_id`), + CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`), CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -1357,15 +1428,16 @@ CREATE TABLE `policy_membership` ( CREATE TABLE `policy_stats` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `policy_id` int unsigned NOT NULL, - `inherited_team_id` int unsigned NOT NULL DEFAULT '0', + `inherited_team_id` int unsigned DEFAULT NULL, `passing_host_count` mediumint unsigned NOT NULL DEFAULT '0', `failing_host_count` mediumint unsigned NOT NULL DEFAULT '0', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `inherited_team_id_char` char(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (if((`inherited_team_id` is null),_utf8mb4'global',cast(`inherited_team_id` as char charset utf8mb4))) VIRTUAL, PRIMARY KEY (`id`), - UNIQUE KEY `policy_team_unique` (`policy_id`,`inherited_team_id`), + UNIQUE KEY `policy_id` (`policy_id`,`inherited_team_id_char`), CONSTRAINT `policy_stats_ibfk_1` FOREIGN KEY (`policy_id`) REFERENCES `policies` (`id`) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ 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 */; /*!50503 SET character_set_client = utf8mb4 */; @@ -1407,7 +1479,7 @@ CREATE TABLE `query_results` ( `last_fetched` timestamp NOT NULL, `data` json DEFAULT NULL, PRIMARY KEY (`id`), - KEY `query_id` (`query_id`) + KEY `idx_query_id_host_id_last_fetched` (`query_id`,`host_id`,`last_fetched`) ) /*!50100 TABLESPACE `innodb_system` */ 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 */; @@ -1604,8 +1676,16 @@ CREATE TABLE `software_installers` ( `install_script_content_id` int unsigned NOT NULL, `post_install_script_content_id` int unsigned DEFAULT NULL, `storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `uploaded_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `self_service` tinyint(1) NOT NULL DEFAULT '0', + `user_id` int unsigned DEFAULT NULL, + `user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `package_ids` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `extension` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `uninstall_script_content_id` int unsigned NOT NULL, + `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), @@ -1613,10 +1693,14 @@ CREATE TABLE `software_installers` ( KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`), KEY `fk_software_installers_team_id` (`team_id`), KEY `idx_software_installers_platform_title_id` (`platform`,`title_id`), + KEY `fk_software_installers_user_id` (`user_id`), + KEY `fk_uninstall_script_content_id` (`uninstall_script_content_id`), CONSTRAINT `fk_software_installers_install_script_content_id` FOREIGN KEY (`install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_post_install_script_content_id` FOREIGN KEY (`post_install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_team_id` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE + CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_software_installers_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_uninstall_script_content_id` FOREIGN KEY (`uninstall_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE ) /*!50100 TABLESPACE `innodb_system` */ 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 */; @@ -1729,16 +1813,48 @@ CREATE TABLE `vpp_apps_teams` ( `team_id` int unsigned DEFAULT NULL, `global_or_team_id` int NOT NULL DEFAULT '0', `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `self_service` tinyint(1) NOT NULL DEFAULT '0', + `vpp_token_id` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`,`platform`), KEY `team_id` (`team_id`), KEY `adam_id` (`adam_id`,`platform`), + KEY `fk_vpp_apps_teams_vpp_token_id` (`vpp_token_id`), + CONSTRAINT `fk_vpp_apps_teams_vpp_token_id` FOREIGN KEY (`vpp_token_id`) REFERENCES `vpp_tokens` (`id`) ON DELETE CASCADE, CONSTRAINT `vpp_apps_teams_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE, CONSTRAINT `vpp_apps_teams_ibfk_3` FOREIGN KEY (`adam_id`, `platform`) REFERENCES `vpp_apps` (`adam_id`, `platform`) ON DELETE CASCADE ) /*!50100 TABLESPACE `innodb_system` */ 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 */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `vpp_token_teams` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `vpp_token_id` int unsigned NOT NULL, + `team_id` int unsigned DEFAULT NULL, + `null_team_type` enum('none','allteams','noteam') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT 'none', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_vpp_token_teams_team_id` (`team_id`), + KEY `fk_vpp_token_teams_vpp_token_id` (`vpp_token_id`), + CONSTRAINT `fk_vpp_token_teams_team_id` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vpp_token_teams_vpp_token_id` FOREIGN KEY (`vpp_token_id`) REFERENCES `vpp_tokens` (`id`) ON DELETE CASCADE +) 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 */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `vpp_tokens` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `organization_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `location` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `renew_at` timestamp NOT NULL, + `token` blob NOT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_vpp_tokens_location` (`location`) +) /*!50100 TABLESPACE `innodb_system` */ 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 */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `vulnerability_host_counts` ( `cve` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, `team_id` int unsigned NOT NULL DEFAULT '0', diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index e9acb25254..f0ad52acb0 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -80,7 +80,8 @@ func truncateScriptResult(output string) string { return output } -func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) { +func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, + string, error) { const resultExistsStmt = ` SELECT 1 @@ -105,20 +106,27 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f const hostMDMActionsStmt = ` SELECT CASE - WHEN lock_ref = ? THEN 'lock_ref' - WHEN unlock_ref = ? THEN 'unlock_ref' - WHEN wipe_ref = ? THEN 'wipe_ref' + WHEN lock_ref = :execution_id THEN 'lock_ref' + WHEN unlock_ref = :execution_id THEN 'unlock_ref' + WHEN wipe_ref = :execution_id THEN 'wipe_ref' ELSE '' - END AS ref_col + END AS action FROM host_mdm_actions WHERE - host_id = ? + host_id = :host_id + UNION + SELECT 'uninstall' AS action + FROM + host_software_installs + WHERE + execution_id = :execution_id AND host_id = :host_id ` output := truncateScriptResult(result.Output) var hsr *fleet.HostScriptResult + var action string err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var resultExists bool err := sqlx.GetContext(ctx, tx, &resultExists, resultExistsStmt, result.HostID, result.ExecutionID) @@ -154,15 +162,31 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "load updated host script result") } - // look up if that script was a lock/unlock/wipe script for that host, + // look up if that script was a lock/unlock/wipe/uninstall script for that host, // and if so update the host_mdm_actions table accordingly. - var refCol string - err = sqlx.GetContext(ctx, tx, &refCol, hostMDMActionsStmt, result.ExecutionID, result.ExecutionID, result.ExecutionID, result.HostID) + namedArgs := map[string]any{ + "host_id": result.HostID, + "execution_id": result.ExecutionID, + } + stmt, args, err := sqlx.Named(hostMDMActionsStmt, namedArgs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build named query for host mdm actions") + } + err = sqlx.GetContext(ctx, tx, &action, stmt, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { // ignore ErrNoRows, refCol will be empty return ctxerr.Wrap(ctx, err, "lookup host script corresponding mdm action") } - if refCol != "" { - err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0) + + switch action { + case "": + // do nothing + case "uninstall": + err = updateUninstallStatusFromResult(ctx, tx, result.HostID, result.ExecutionID, result.ExitCode) + if err != nil { + return ctxerr.Wrap(ctx, err, "update host uninstall action based on script result") + } + default: // lock/unlock/wipe + err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, action, result.ExitCode == 0) if err != nil { return ctxerr.Wrap(ctx, err, "update host mdm action based on script result") } @@ -171,9 +195,9 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f return nil }) if err != nil { - return nil, err + return nil, "", err } - return hsr, nil + return hsr, action, nil } func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { @@ -392,6 +416,25 @@ WHERE return contents, nil } +func (ds *Datastore) GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error) { + const getStmt = ` +SELECT + sc.contents +FROM + script_contents sc +WHERE + sc.id = ? +` + var contents []byte + if err := sqlx.GetContext(ctx, ds.reader(ctx), &contents, getStmt, id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, notFound("Script").WithID(id) + } + return nil, ctxerr.Wrap(ctx, err, "get any script contents") + } + return contents, nil +} + func (ds *Datastore) DeleteScript(ctx context.Context, id uint) error { _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM scripts WHERE id = ?`, id) if err != nil { @@ -1100,6 +1143,16 @@ func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result") } +func updateUninstallStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string, exitCode int) error { + stmt := ` + UPDATE host_software_installs SET uninstall_script_exit_code = ? WHERE execution_id = ? AND host_id = ? + ` + if _, err := tx.ExecContext(ctx, stmt, exitCode, executionID, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "update uninstall status from result") + } + return nil +} + func (ds *Datastore) CleanupUnusedScriptContents(ctx context.Context) error { deleteStmt := ` DELETE FROM @@ -1111,7 +1164,7 @@ WHERE SELECT 1 FROM scripts WHERE script_content_id = script_contents.id) AND NOT EXISTS ( SELECT 1 FROM software_installers si - WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id) + WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id) ) ` _, err := ds.writer(ctx).ExecContext(ctx, deleteStmt) diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index b3ccd430a4..7666f0ae4f 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -37,6 +37,7 @@ func TestScripts(t *testing.T) { {"TestLockUnlockManually", testLockUnlockManually}, {"TestInsertScriptContents", testInsertScriptContents}, {"TestCleanupUnusedScriptContents", testCleanupUnusedScriptContents}, + {"TestGetAnyScriptContents", testGetAnyScriptContents}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -87,7 +88,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { require.Equal(t, createdScript.ID, pending[0].ID) // record a result for this execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + hsr, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdScript.ExecutionID, Output: "foo", @@ -96,9 +97,11 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { Timeout: 300, }) require.NoError(t, err) + assert.Empty(t, action) + assert.NotNil(t, hsr) // record a duplicate result for this execution, will be ignored - hsr, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + hsr, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdScript.ExecutionID, Output: "foobarbaz", @@ -163,7 +166,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { strings.Repeat("j", 1000) + strings.Repeat("k", 1000) - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdScript.ExecutionID, Output: largeOutput, @@ -238,7 +241,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - unsignedScriptResult, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + unsignedScriptResult, _, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdUnsignedScript.ExecutionID, Output: "foo", @@ -261,6 +264,8 @@ func testScripts(t *testing.T, ds *Datastore) { // get unknown script contents _, err = ds.GetScriptContents(ctx, 123) require.ErrorAs(t, err, &nfe) + _, err = ds.GetAnyScriptContents(ctx, 123) + require.ErrorAs(t, err, &nfe) // create global scriptGlobal scriptGlobal, err := ds.NewScript(ctx, &fleet.Script{ @@ -282,6 +287,9 @@ func testScripts(t *testing.T, ds *Datastore) { contents, err := ds.GetScriptContents(ctx, scriptGlobal.ID) require.NoError(t, err) require.Equal(t, "echo", string(contents)) + contents, err = ds.GetAnyScriptContents(ctx, scriptGlobal.ID) + require.NoError(t, err) + require.Equal(t, "echo", string(contents)) // create team script but team does not exist _, err = ds.NewScript(ctx, &fleet.Script{ @@ -315,6 +323,9 @@ func testScripts(t *testing.T, ds *Datastore) { contents, err = ds.GetScriptContents(ctx, scriptTeam.ID) require.NoError(t, err) require.Equal(t, "echo 'team'", string(contents)) + contents, err = ds.GetAnyScriptContents(ctx, scriptTeam.ID) + require.NoError(t, err) + require.Equal(t, "echo 'team'", string(contents)) // try to create another team script with the same name _, err = ds.NewScript(ctx, &fleet.Script{ @@ -781,12 +792,13 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { require.True(t, status.IsPendingLock()) // simulate a successful result for the lock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: s.HostID, ExecutionID: s.ExecutionID, ExitCode: 0, }) require.NoError(t, err) + assert.Equal(t, "lock_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) @@ -832,12 +844,13 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) { require.True(t, status.IsPendingUnlock()) // simulate a successful result for the unlock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: s.HostID, ExecutionID: s.ExecutionID, ExitCode: 0, }) require.NoError(t, err) + assert.Equal(t, "unlock_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) @@ -874,12 +887,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, true, false, false, false, true, false) // simulate a successful result for the lock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.LockScript.ExecutionID, ExitCode: 0, }) require.NoError(t, err) + assert.Equal(t, "lock_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) @@ -899,12 +913,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, false, true, false, true, false, false) // simulate a failed result for the unlock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.UnlockScript.ExecutionID, ExitCode: -1, }) require.NoError(t, err) + assert.Equal(t, "unlock_ref", action) // still locked status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) @@ -925,12 +940,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, false, true, false, true, false, false) // this time simulate a successful result for the unlock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.UnlockScript.ExecutionID, ExitCode: 0, }) require.NoError(t, err) + assert.Equal(t, "unlock_ref", action) // host is now unlocked status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) @@ -951,12 +967,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, true, false, false, false, true, false) // simulate a failed result for the lock script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.LockScript.ExecutionID, ExitCode: 2, }) require.NoError(t, err) + assert.Equal(t, "lock_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) @@ -1009,12 +1026,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, true, false, false, false, false, true) // simulate a failed result for the wipe script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.WipeScript.ExecutionID, ExitCode: 1, }) require.NoError(t, err) + assert.Equal(t, "wipe_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) @@ -1034,12 +1052,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { checkLockWipeState(t, status, true, false, false, false, false, true) // simulate a successful result for the wipe script execution - _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: hostID, ExecutionID: status.WipeScript.ExecutionID, ExitCode: 0, }) require.NoError(t, err) + assert.Equal(t, "wipe_ref", action) status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) @@ -1138,6 +1157,8 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { s, err := ds.NewScript(ctx, s) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Bob", "bob@example.com", true) + // create a sync script execution res, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ScriptContents: "echo something_else", SyncRequest: true}) require.NoError(t, err) @@ -1145,6 +1166,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { // create a software install that references scripts swi, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install-script", + UninstallScript: "uninstall-script", PreInstallQuery: "SELECT 1", PostInstallScript: "post-install-script", InstallerFile: bytes.NewReader([]byte("hello")), @@ -1153,6 +1175,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -1164,7 +1187,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { stmt := `SELECT id, HEX(md5_checksum) as md5_checksum FROM script_contents` err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt) require.NoError(t, err) - require.Len(t, sc, 4) + require.Len(t, sc, 5) // this should only remove the script_contents of the saved script, since the sync script is // still "in use" by the script execution @@ -1173,15 +1196,17 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { sc = []scriptContents{} err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt) require.NoError(t, err) - require.Len(t, sc, 3) + require.Len(t, sc, 4) require.ElementsMatch(t, []string{ md5ChecksumScriptContent(res.ScriptContents), md5ChecksumScriptContent("install-script"), md5ChecksumScriptContent("post-install-script"), + md5ChecksumScriptContent("uninstall-script"), }, []string{ sc[0].Checksum, sc[1].Checksum, sc[2].Checksum, + sc[3].Checksum, }) // remove the software installer from the DB @@ -1201,12 +1226,14 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { swi, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ PreInstallQuery: "SELECT 1", InstallScript: "install-script", + UninstallScript: "uninstall-script", InstallerFile: bytes.NewReader([]byte("hello")), StorageID: "storage1", Filename: "file1", Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -1215,7 +1242,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { sc = []scriptContents{} err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt) require.NoError(t, err) - require.Len(t, sc, 2) + require.Len(t, sc, 3) // remove the software installer from the DB err = ds.DeleteSoftwareInstaller(ctx, swi) @@ -1229,3 +1256,15 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { require.Len(t, sc, 1) require.Equal(t, md5ChecksumScriptContent(res.ScriptContents), sc[0].Checksum) } + +func testGetAnyScriptContents(t *testing.T, ds *Datastore) { + ctx := context.Background() + contents := `echo foobar;` + res, err := insertScriptContents(ctx, ds.writer(ctx), contents) + require.NoError(t, err) + id, _ := res.LastInsertId() + + result, err := ds.GetAnyScriptContents(ctx, uint(id)) + require.NoError(t, err) + require.Equal(t, contents, string(result)) +} diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 76ab0da294..1c112c07c0 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -367,6 +367,10 @@ func (ds *Datastore) applyChangesForNewSoftwareDB( } r.Inserted = inserted + if err = checkForDeletedInstalledSoftware(ctx, tx, deleted, inserted, hostID); err != nil { + return err + } + if err = updateModifiedHostSoftwareDB(ctx, tx, hostID, current, incoming, ds.minLastOpenedAtDiff); err != nil { return err } @@ -383,6 +387,76 @@ func (ds *Datastore) applyChangesForNewSoftwareDB( return r, err } +func checkForDeletedInstalledSoftware(ctx context.Context, tx sqlx.ExtContext, deleted []fleet.Software, inserted []fleet.Software, + hostID uint, +) error { + // Between deleted and inserted software, check which software titles were deleted. + // If software titles were deleted, get the software titles of the installed software. + // See if deleted titles match installed software titles. + // If so, mark the installed software as removed. + var deletedTitles map[string]struct{} + if len(deleted) > 0 { + deletedTitles = make(map[string]struct{}, len(deleted)) + for _, d := range deleted { + // We don't support installing browser plugins as of 2024/08/22 + if d.Browser == "" { + deletedTitles[UniqueSoftwareTitleStr(d.Name, d.Source, d.BundleIdentifier)] = struct{}{} + } + } + for _, i := range inserted { + // We don't support installing browser plugins as of 2024/08/22 + if i.Browser == "" { + key := UniqueSoftwareTitleStr(i.Name, i.Source, i.BundleIdentifier) + if _, ok := deletedTitles[key]; ok { + delete(deletedTitles, key) + } + } + } + } + if len(deletedTitles) > 0 { + installedTitles, err := getInstalledByFleetSoftwareTitles(ctx, tx, hostID) + if err != nil { + return err + } + type deletedValue struct { + vpp bool + } + deletedTitleIDs := make(map[uint]deletedValue, 0) + for _, title := range installedTitles { + bundleIdentifier := "" + if title.BundleIdentifier != nil { + bundleIdentifier = *title.BundleIdentifier + } + key := UniqueSoftwareTitleStr(title.Name, title.Source, bundleIdentifier) + if _, ok := deletedTitles[key]; ok { + deletedTitleIDs[title.ID] = deletedValue{vpp: title.VPPAppsCount > 0} + } + } + if len(deletedTitleIDs) > 0 { + IDs := make([]uint, 0, len(deletedTitleIDs)) + vppIDs := make([]uint, 0, len(deletedTitleIDs)) + for id, value := range deletedTitleIDs { + if value.vpp { + vppIDs = append(vppIDs, id) + } else { + IDs = append(IDs, id) + } + } + if len(IDs) > 0 { + if err = markHostSoftwareInstallsRemoved(ctx, tx, hostID, IDs); err != nil { + return err + } + } + if len(vppIDs) > 0 { + if err = markHostVPPSoftwareInstallsRemoved(ctx, tx, hostID, vppIDs); err != nil { + return err + } + } + } + } + return nil +} + func (ds *Datastore) getExistingSoftware( ctx context.Context, current map[string]fleet.Software, incoming map[string]fleet.Software, ) ( @@ -640,7 +714,7 @@ func (ds *Datastore) insertNewInstalledHostSoftwareDB( UPDATE software s JOIN software_titles st ON s.bundle_identifier = st.bundle_identifier AND - IF(s.source IN ('ios_apps', 'ipados_apps'), s.source = st.source, 1) + IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1) SET s.title_id = st.id WHERE s.title_id IS NULL OR s.title_id != st.id @@ -979,19 +1053,46 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e } if opts.IncludeCVEScores { - ds = ds. - LeftJoin( + + baseJoinConditions := goqu.Ex{ + "c.cve": goqu.I("scv.cve"), + } + + if opts.KnownExploit || opts.MinimumCVSS > 0 || opts.MaximumCVSS > 0 { + + if opts.KnownExploit { + baseJoinConditions["c.cisa_known_exploit"] = true + } + + if opts.MinimumCVSS > 0 { + baseJoinConditions["c.cvss_score"] = goqu.Op{"gte": opts.MinimumCVSS} + } + + if opts.MaximumCVSS > 0 { + baseJoinConditions["c.cvss_score"] = goqu.Op{"lte": opts.MaximumCVSS} + } + + ds = ds.InnerJoin( goqu.I("cve_meta").As("c"), - goqu.On(goqu.I("c.cve").Eq(goqu.I("scv.cve"))), - ). - SelectAppend( - goqu.MAX("c.cvss_score").As("cvss_score"), // for ordering - goqu.MAX("c.epss_probability").As("epss_probability"), // for ordering - goqu.MAX("c.cisa_known_exploit").As("cisa_known_exploit"), // for ordering - goqu.MAX("c.published").As("cve_published"), // for ordering - goqu.MAX("c.description").As("description"), // for ordering - goqu.MAX("scv.resolved_in_version").As("resolved_in_version"), // for ordering + goqu.On(baseJoinConditions), ) + + } else { + ds = ds. + LeftJoin( + goqu.I("cve_meta").As("c"), + goqu.On(baseJoinConditions), + ) + } + + ds = ds.SelectAppend( + goqu.MAX("c.cvss_score").As("cvss_score"), // for ordering + goqu.MAX("c.epss_probability").As("epss_probability"), // for ordering + goqu.MAX("c.cisa_known_exploit").As("cisa_known_exploit"), // for ordering + goqu.MAX("c.published").As("cve_published"), // for ordering + goqu.MAX("c.description").As("description"), // for ordering + goqu.MAX("scv.resolved_in_version").As("resolved_in_version"), // for ordering + ) } if match := opts.ListOptions.MatchQuery; match != "" { @@ -1294,6 +1395,12 @@ func (ds *Datastore) ListSoftwareCPEs(ctx context.Context) ([]fleet.SoftwareCPE, } func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { + if !opt.VulnerableOnly && (opt.MinimumCVSS > 0 || opt.MaximumCVSS > 0 || opt.KnownExploit) { + return nil, nil, fleet.NewInvalidArgumentError( + "query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true", + ) + } + software, err := listSoftwareDB(ctx, ds.reader(ctx), opt) if err != nil { return nil, nil, err @@ -1429,8 +1536,6 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint, teamID *uint, in return nil, err } - fmt.Println(sql, args) - var results []softwareCVE err = sqlx.SelectContext(ctx, ds.reader(ctx), &results, sql, args...) if err != nil { @@ -1667,7 +1772,7 @@ FROM ( NOT EXISTS ( SELECT 1 FROM software_titles st WHERE s.bundle_identifier = st.bundle_identifier AND - IF(s.source IN ('ios_apps', 'ipados_apps'), s.source = st.source, 1) + IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1) ) AND COALESCE(bundle_identifier, '') != '' @@ -1718,7 +1823,7 @@ AND COALESCE(s.bundle_identifier, '') = ''; UPDATE software s JOIN software_titles st ON s.bundle_identifier = st.bundle_identifier AND - IF(s.source IN ('ios_apps', 'ipados_apps'), s.source = st.source, 1) + IF(s.source IN ('apps', 'ios_apps', 'ipados_apps'), s.source = st.source, 1) SET s.title_id = st.id WHERE s.title_id IS NULL OR s.title_id != st.id; @@ -1916,7 +2021,7 @@ func (ds *Datastore) InsertSoftwareVulnerability( return false, ctxerr.Wrap(ctx, err, "insert software vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) ListSoftwareVulnerabilitiesByHostIDsSource( @@ -2051,56 +2156,38 @@ func (ds *Datastore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]flee return result, nil } -// tblAlias is the table alias to use as prefix for the host_software_installs -// column names, no prefix used if empty. -// colAlias is the name to be assigned to the computed status column, pass -// empty to have the value only, no column alias set. -func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { - if tblAlias != "" { - tblAlias += "." - } - if colAlias != "" { - colAlias = " AS " + colAlias - } - // the computed column assumes that all results (pre, install and post) are - // stored at once, so that if there is an exit code for the install script - // and none for the post-install, it is because there is no post-install. - return fmt.Sprintf(` - CASE - WHEN %[1]spost_install_script_exit_code IS NOT NULL AND - %[1]spost_install_script_exit_code = 0 THEN :software_status_installed - - WHEN %[1]spost_install_script_exit_code IS NOT NULL AND - %[1]spost_install_script_exit_code != 0 THEN :software_status_failed - - WHEN %[1]sinstall_script_exit_code IS NOT NULL AND - %[1]sinstall_script_exit_code = 0 THEN :software_status_installed - - WHEN %[1]sinstall_script_exit_code IS NOT NULL AND - %[1]sinstall_script_exit_code != 0 THEN :software_status_failed - - WHEN %[1]spre_install_query_output IS NOT NULL AND - %[1]spre_install_query_output = '' THEN :software_status_failed - - WHEN %[1]shost_id IS NOT NULL THEN :software_status_pending - - ELSE NULL -- not installed from Fleet installer - END %[2]s `, tblAlias, colAlias) -} - func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { var onlySelfServiceClause string if opts.SelfServiceOnly { - onlySelfServiceClause = ` AND si.self_service = 1 ` + onlySelfServiceClause = ` AND ( si.self_service = 1 OR vat.self_service = 1 ) ` } - var onlyVulnerableClause string + var onlyVulnerableJoin string if opts.VulnerableOnly { - onlyVulnerableClause = ` -AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id = s.id WHERE s.title_id = st.id) + onlyVulnerableJoin = ` +INNER JOIN software_cve scve ON scve.software_id = s.id ` } + softwareIsInstalledOnHostClause := fmt.Sprintf(` + EXISTS ( + SELECT 1 + FROM + host_software hs + INNER JOIN + software s ON hs.software_id = s.id + %s + WHERE + hs.host_id = :host_id AND + s.title_id = st.id + ) OR `, onlyVulnerableJoin) + status := fmt.Sprintf(`COALESCE(%s, %s)`, "hsi.last_status", vppAppHostStatusNamedQuery("hvsi", "ncr", "")) + if opts.OnlyAvailableForInstall { + // Get software that has a package/VPP installer but was not installed with Fleet + softwareIsInstalledOnHostClause = fmt.Sprintf(` %s IS NULL AND (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s`, status, + softwareIsInstalledOnHostClause) + } + // this statement lists only the software that is reported as installed on // the host or has been attempted to be installed on the host. stmtInstalled := fmt.Sprintf(` @@ -2111,57 +2198,91 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id si.self_service as package_self_service, si.filename as package_name, si.version as package_version, - -- in a future iteration, will be supported for VPP apps - NULL as vpp_app_self_service, - vap.adam_id as vpp_app_adam_id, + vat.self_service as vpp_app_self_service, + vat.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, NULLIF(vap.icon_url, '') as vpp_app_icon_url, - COALESCE(hsi.created_at, hvsi.created_at) as last_install_installed_at, - COALESCE(hsi.execution_id, hvsi.command_uuid) as last_install_install_uuid, - -- get either the softare installer status or the vpp app status - COALESCE(%s, %s) as status + COALESCE(hsi.last_installed_at, hvsi.created_at) as last_install_installed_at, + COALESCE(hsi.last_install_execution_id, hvsi.command_uuid) as last_install_install_uuid, + hsi.last_uninstalled_at as last_uninstall_uninstalled_at, + hsi.last_uninstall_execution_id as last_uninstall_script_execution_id, + -- get either the software installer status or the vpp app status + %s as status FROM software_titles st LEFT OUTER JOIN - software_installers si ON st.id = si.title_id + software_installers si ON st.id = si.title_id AND si.global_or_team_id = :global_or_team_id + LEFT OUTER JOIN -- get the latest status and install/uninstall attempts (merge 3 host_software_installs rows into 1) + ( + SELECT + hsi_group.host_id, + hsi_group.software_installer_id, + TIMESTAMP(GROUP_CONCAT(hsi_installed_at)) as last_installed_at, + GROUP_CONCAT(hsi_install_execution_id) as last_install_execution_id, + TIMESTAMP(GROUP_CONCAT(hsi_uninstalled_at)) as last_uninstalled_at, + GROUP_CONCAT(hsi_uninstall_execution_id) as last_uninstall_execution_id, + IF(GROUP_CONCAT(hsi_status) = '', NULL, GROUP_CONCAT(hsi_status)) as last_status + FROM ( + -- get latest install/uninstall status + SELECT + host_id, software_installer_id, + NULL as hsi_installed_at, NULL as hsi_install_execution_id, + NULL as hsi_uninstalled_at, NULL as hsi_uninstall_execution_id, + -- get the status of the latest attempt; 27-1 is the length of the timestamp + SUBSTRING(MAX(CONCAT(created_at, COALESCE(status, ''))), 27) AS hsi_status + FROM host_software_installs + WHERE host_id = :host_id AND removed = 0 + GROUP BY host_id, software_installer_id + UNION + -- get latest install attempt + SELECT + host_id, software_installer_id, + MAX(created_at) as hsi_installed_at, + -- get the execution_id of the latest attempt; 27-1 is the length of the timestamp + SUBSTRING(MAX(CONCAT(created_at, execution_id)), 27) AS hsi_install_execution_id, + NULL as hsi_uninstalled_at, NULL as hsi_uninstall_execution_id, + NULL as hsi_status + FROM host_software_installs + WHERE host_id = :host_id AND removed = 0 AND uninstall = 0 + GROUP BY host_id, software_installer_id + UNION + -- get latest uninstall attempt + SELECT + host_id, software_installer_id, + NULL as hsi_installed_at, NULL as hsi_install_execution_id, + MAX(created_at) as hsi_uninstalled_at, + -- get the execution_id of the latest attempt; 27-1 is the length of the timestamp + SUBSTRING(MAX(CONCAT(created_at, execution_id)), 27) AS hsi_uninstall_execution_id, + NULL as hsi_status + FROM host_software_installs + WHERE host_id = :host_id AND removed = 0 AND uninstall = 1 + GROUP BY host_id, software_installer_id + ) as hsi_group + GROUP BY hsi_group.host_id, hsi_group.software_installer_id + ) as hsi ON si.id = hsi.software_installer_id LEFT OUTER JOIN - host_software_installs hsi ON si.id = hsi.software_installer_id AND hsi.host_id = :host_id + vpp_apps vap ON st.id = vap.title_id AND vap.platform = :host_platform LEFT OUTER JOIN - vpp_apps vap ON st.id = vap.title_id + vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform AND vat.global_or_team_id = :global_or_team_id LEFT OUTER JOIN - host_vpp_software_installs hvsi ON vap.adam_id = hvsi.adam_id AND vap.platform = hvsi.platform AND hvsi.host_id = :host_id + host_vpp_software_installs hvsi ON vat.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id AND hvsi.removed = 0 LEFT OUTER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid WHERE - -- use the latest install only - ( hsi.id IS NULL OR hsi.id = ( - SELECT hsi2.id - FROM host_software_installs hsi2 - WHERE hsi2.host_id = hsi.host_id AND hsi2.software_installer_id = hsi.software_installer_id - ORDER BY hsi2.created_at DESC - LIMIT 1 ) ) AND + -- use the latest VPP install attempt only ( hvsi.id IS NULL OR hvsi.id = ( SELECT hvsi2.id FROM host_vpp_software_installs hvsi2 - WHERE hvsi2.host_id = hvsi.host_id AND hvsi2.adam_id = hvsi.adam_id AND hvsi2.platform = hvsi.platform + WHERE hvsi2.host_id = hvsi.host_id AND hvsi2.adam_id = hvsi.adam_id AND hvsi2.platform = hvsi.platform AND hvsi2.removed = 0 ORDER BY hvsi2.created_at DESC LIMIT 1 ) ) AND - -- software is installed on host - ( EXISTS ( - SELECT 1 - FROM - host_software hs - INNER JOIN - software s ON hs.software_id = s.id - WHERE - hs.host_id = :host_id AND - s.title_id = st.id - ) OR - -- or software install has been attempted on host (via installer or VPP app) - hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL ) + + -- software is installed on host or software install has been attempted + -- on host (via installer or VPP app). If only available for install is + -- requested, then the software installed on host clause is empty. + ( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL ) %s - %s -`, softwareInstallerHostStatusNamedQuery("hsi", ""), vppAppHostStatusNamedQuery("hvsi", "ncr", ""), onlySelfServiceClause, onlyVulnerableClause) +`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause) // this statement lists only the software that has never been installed nor // attempted to be installed on the host, but that is available to be @@ -2174,13 +2295,14 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id si.self_service as package_self_service, si.filename as package_name, si.version as package_version, - -- in a future iteration, will be supported for VPP apps - NULL as vpp_app_self_service, - vap.adam_id as vpp_app_adam_id, + vat.self_service as vpp_app_self_service, + vat.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, NULLIF(vap.icon_url, '') as vpp_app_icon_url, NULL as last_install_installed_at, NULL as last_install_install_uuid, + NULL as last_uninstall_uninstalled_at, + NULL as last_uninstall_script_execution_id, NULL as status FROM software_titles st @@ -2211,7 +2333,8 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id host_software_installs hsi WHERE hsi.host_id = :host_id AND - hsi.software_installer_id = si.id + hsi.software_installer_id = si.id AND + hsi.removed = 0 ) AND NOT EXISTS ( SELECT 1 @@ -2219,7 +2342,8 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id host_vpp_software_installs hvsi WHERE hvsi.host_id = :host_id AND - hvsi.adam_id = vat.adam_id + hvsi.adam_id = vat.adam_id AND + hvsi.removed = 0 ) AND -- either the software installer or the vpp app exists for the host's team ( si.id IS NOT NULL OR vat.platform = :host_platform ) @@ -2242,6 +2366,8 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id vpp_app_icon_url, last_install_installed_at, last_install_install_uuid, + last_uninstall_uninstalled_at, + last_uninstall_script_execution_id, status ` @@ -2252,9 +2378,9 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id namedArgs := map[string]any{ "host_id": host.ID, "host_platform": host.FleetPlatform(), - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_pending": fleet.SoftwareInstallerPending, - "software_status_installed": fleet.SoftwareInstallerInstalled, + "software_status_failed": fleet.SoftwareInstallFailed, + "software_status_pending": fleet.SoftwareInstallPending, + "software_status_installed": fleet.SoftwareInstalled, "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, "mdm_status_error": fleet.MDMAppleStatusError, "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, @@ -2262,20 +2388,14 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id } stmt := stmtInstalled - if opts.AvailableForInstall || (opts.IncludeAvailableForInstall && !opts.VulnerableOnly) { - namedArgs["vpp_apps_platforms"] = []fleet.AppleDevicePlatform{fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform} + if opts.OnlyAvailableForInstall || (opts.IncludeAvailableForInstall && !opts.VulnerableOnly) { + namedArgs["vpp_apps_platforms"] = fleet.VPPAppsPlatforms if fleet.IsLinux(host.Platform) { namedArgs["host_compatible_platforms"] = fleet.HostLinuxOSs } else { namedArgs["host_compatible_platforms"] = []string{host.FleetPlatform()} } - if opts.AvailableForInstall { - // Only available for install software - stmt = stmtAvailable - } else { - // All software, including available for install - stmt += ` UNION ` + stmtAvailable - } + stmt += ` UNION ` + stmtAvailable } // must resolve the named bindings here, before adding the searchLike which @@ -2308,15 +2428,17 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id type hostSoftware struct { fleet.HostSoftwareWithInstaller - LastInstallInstalledAt *time.Time `db:"last_install_installed_at"` - LastInstallInstallUUID *string `db:"last_install_install_uuid"` - PackageSelfService *bool `db:"package_self_service"` - PackageName *string `db:"package_name"` - PackageVersion *string `db:"package_version"` - VPPAppSelfService *bool `db:"vpp_app_self_service"` - VPPAppAdamID *string `db:"vpp_app_adam_id"` - VPPAppVersion *string `db:"vpp_app_version"` - VPPAppIconURL *string `db:"vpp_app_icon_url"` + LastInstallInstalledAt *time.Time `db:"last_install_installed_at"` + LastInstallInstallUUID *string `db:"last_install_install_uuid"` + LastUninstallUninstalledAt *time.Time `db:"last_uninstall_uninstalled_at"` + LastUninstallScriptExecutionID *string `db:"last_uninstall_script_execution_id"` + PackageSelfService *bool `db:"package_self_service"` + PackageName *string `db:"package_name"` + PackageVersion *string `db:"package_version"` + VPPAppSelfService *bool `db:"vpp_app_self_service"` + VPPAppAdamID *string `db:"vpp_app_adam_id"` + VPPAppVersion *string `db:"vpp_app_version"` + VPPAppIconURL *string `db:"vpp_app_icon_url"` } var hostSoftwareList []*hostSoftware if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil { @@ -2350,6 +2472,16 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id hs.SoftwarePackage.LastInstall.InstalledAt = *hs.LastInstallInstalledAt } } + + // promote the last uninstall info to the proper destination fields + if hs.LastUninstallScriptExecutionID != nil && *hs.LastUninstallScriptExecutionID != "" { + hs.SoftwarePackage.LastUninstall = &fleet.HostSoftwareUninstall{ + ExecutionID: *hs.LastUninstallScriptExecutionID, + } + if hs.LastUninstallUninstalledAt != nil { + hs.SoftwarePackage.LastUninstall.UninstalledAt = *hs.LastUninstallUninstalledAt + } + } } // promote the VPP app id and version to the proper destination fields @@ -2544,3 +2676,85 @@ func (ds *Datastore) SetHostSoftwareInstallResult(ctx context.Context, result *f } return nil } + +func getInstalledByFleetSoftwareTitles(ctx context.Context, qc sqlx.QueryerContext, hostID uint) ([]fleet.SoftwareTitle, error) { + // We are overloading vpp_apps_count to indicate whether installed title is a VPP app or not. + const stmt = ` +SELECT + st.id, + st.name, + st.source, + st.browser, + st.bundle_identifier, + 0 as vpp_apps_count +FROM software_titles st +INNER JOIN software_installers si ON si.title_id = st.id +INNER JOIN host_software_installs hsi ON hsi.host_id = :host_id AND hsi.software_installer_id = si.id +WHERE hsi.removed = 0 AND hsi.status = :software_status_installed + +UNION + +SELECT + st.id, + st.name, + st.source, + st.browser, + st.bundle_identifier, + 1 as vpp_apps_count +FROM software_titles st +INNER JOIN vpp_apps vap ON vap.title_id = st.id +INNER JOIN host_vpp_software_installs hvsi ON hvsi.host_id = :host_id AND hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform +INNER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid +WHERE hvsi.removed = 0 AND ncr.status = :mdm_status_acknowledged +` + selectStmt, args, err := sqlx.Named(stmt, map[string]interface{}{ + "host_id": hostID, + "software_status_installed": fleet.SoftwareInstalled, + "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build query to get installed software titles") + } + + var titles []fleet.SoftwareTitle + if err := sqlx.SelectContext(ctx, qc, &titles, selectStmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get installed software titles") + } + return titles, nil +} + +func markHostSoftwareInstallsRemoved(ctx context.Context, ex sqlx.ExtContext, hostID uint, titleIDs []uint) error { + const stmt = ` +UPDATE host_software_installs hsi +INNER JOIN software_installers si ON hsi.software_installer_id = si.id +INNER JOIN software_titles st ON si.title_id = st.id +SET hsi.removed = 1 +WHERE hsi.host_id = ? AND st.id IN (?) +` + stmtExpanded, args, err := sqlx.In(stmt, hostID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build query args to mark host software install removed") + } + if _, err := ex.ExecContext(ctx, stmtExpanded, args...); err != nil { + return ctxerr.Wrap(ctx, err, "mark host software install removed") + } + return nil +} + +func markHostVPPSoftwareInstallsRemoved(ctx context.Context, ex sqlx.ExtContext, hostID uint, titleIDs []uint) error { + const stmt = ` +UPDATE host_vpp_software_installs hvsi +INNER JOIN vpp_apps vap ON hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform +INNER JOIN software_titles st ON vap.title_id = st.id +SET hvsi.removed = 1 +WHERE hvsi.host_id = ? AND st.id IN (?) +` + stmtExpanded, args, err := sqlx.In(stmt, hostID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build query args to mark host vpp software install removed") + } + if _, err := ex.ExecContext(ctx, stmtExpanded, args...); err != nil { + return ctxerr.Wrap(ctx, err, "mark host vpp software install removed") + } + return nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index c491d38139..7d7f0169e3 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -23,14 +24,12 @@ func (ds *Datastore) ListPendingSoftwareInstalls(ctx context.Context, hostID uin WHERE host_id = ? AND - install_script_exit_code IS NULL - AND - pre_install_query_output IS NULL + status = ? ORDER BY created_at ASC ` var results []string - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID); err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID, fleet.SoftwareInstallPending); err != nil { return nil, ctxerr.Wrap(ctx, err, "list pending software installs") } return results, nil @@ -45,6 +44,7 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId hsi.self_service AS self_service, COALESCE(si.pre_install_query, '') AS pre_install_condition, inst.contents AS install_script, + uninst.contents AS uninstall_script, COALESCE(pisnt.contents, '') AS post_install_script FROM host_software_installs hsi @@ -54,6 +54,9 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id + LEFT OUTER JOIN + script_contents uninst + ON uninst.id = si.uninstall_script_content_id LEFT OUTER JOIN script_contents pisnt ON pisnt.id = si.post_install_script_content_id @@ -85,6 +88,11 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, ctxerr.Wrap(ctx, err, "get or generate install script contents ID") } + uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID") + } + var postInstallScriptID *uint if payload.PostInstallScript != "" { sid, err := ds.getOrGenerateScriptContentsID(ctx, payload.PostInstallScript) @@ -111,13 +119,19 @@ INSERT INTO software_installers ( title_id, storage_id, filename, + extension, version, + package_ids, install_script_content_id, pre_install_query, post_install_script_content_id, + uninstall_script_content_id, platform, - self_service -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + self_service, + user_id, + user_name, + user_email +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))` args := []interface{}{ tid, @@ -125,12 +139,18 @@ INSERT INTO software_installers ( titleID, payload.StorageID, payload.Filename, + payload.Extension, payload.Version, + strings.Join(payload.PackageIDs, ","), installScriptID, payload.PreInstallQuery, postInstallScriptID, + uninstallScriptID, payload.Platform, payload.SelfService, + payload.UserID, + payload.UserID, + payload.UserID, } res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) @@ -196,6 +216,106 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles") } +func (ds *Datastore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error { + _, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "update software installer") + } + + return nil +} + +func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { + if payload.InstallScript == nil || payload.UninstallScript == nil || payload.PreInstallQuery == nil || payload.SelfService == nil { + return ctxerr.Wrap(ctx, errors.New("missing installer update payload fields"), "update installer record") + } + + installScriptID, err := ds.getOrGenerateScriptContentsID(ctx, *payload.InstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate install script contents ID") + } + + uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, *payload.UninstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID") + } + + var postInstallScriptID *uint + if payload.PostInstallScript != nil && *payload.PostInstallScript != "" { // pointer because optional + sid, err := ds.getOrGenerateScriptContentsID(ctx, *payload.PostInstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate post-install script contents ID") + } + postInstallScriptID = &sid + } + + touchUploaded := "" + if payload.InstallerFile != nil { + touchUploaded = ", uploaded_at = NOW()" + } + + stmt := fmt.Sprintf(`UPDATE software_installers SET + storage_id = ?, + filename = ?, + version = ?, + package_ids = ?, + install_script_content_id = ?, + pre_install_query = ?, + post_install_script_content_id = ?, + uninstall_script_content_id = ?, + self_service = ?, + user_id = ?, + user_name = (SELECT name FROM users WHERE id = ?), + user_email = (SELECT email FROM users WHERE id = ?) %s + WHERE id = ?`, touchUploaded) + + args := []interface{}{ + payload.StorageID, + payload.Filename, + payload.Version, + strings.Join(payload.PackageIDs, ","), + installScriptID, + *payload.PreInstallQuery, + postInstallScriptID, + uninstallScriptID, + *payload.SelfService, + payload.UserID, + payload.UserID, + payload.UserID, + payload.InstallerID, + } + + _, err = ds.writer(ctx).ExecContext(ctx, stmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "update software installer") + } + + return nil +} + +func (ds *Datastore) ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) { + query := ` + SELECT 1 + FROM + host_software_installs + WHERE + software_installer_id = ? + AND + host_id = ? + AND + install_script_exit_code IS NULL +` + var access bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &access, query, installerID, hostID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, ctxerr.Wrap(ctx, err, "check software installer association to host") + } + return true, nil +} + func (ds *Datastore) GetSoftwareInstallerMetadataByID(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { query := ` SELECT @@ -203,13 +323,17 @@ SELECT si.team_id, si.title_id, si.storage_id, + si.package_ids, si.filename, + si.extension, si.version, si.install_script_content_id, si.pre_install_query, si.post_install_script_content_id, + si.uninstall_script_content_id, si.uploaded_at, - COALESCE(st.name, '') AS software_title + COALESCE(st.name, '') AS software_title, + si.platform FROM software_installers si LEFT OUTER JOIN software_titles st ON st.id = si.title_id @@ -231,9 +355,10 @@ WHERE func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { var scriptContentsSelect, scriptContentsFrom string if withScriptContents { - scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pisnt.contents, '') AS post_install_script ` + scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script ` scriptContentsFrom = ` LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id - LEFT OUTER JOIN script_contents pisnt ON pisnt.id = si.post_install_script_content_id ` + LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id + LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id` } query := fmt.Sprintf(` @@ -242,11 +367,14 @@ SELECT si.team_id, si.title_id, si.storage_id, + si.package_ids, si.filename, + si.extension, si.version, si.install_script_content_id, si.pre_install_query, si.post_install_script_content_id, + si.uninstall_script_content_id, si.uploaded_at, si.self_service, COALESCE(st.name, '') AS software_title @@ -276,9 +404,21 @@ WHERE return &dest, nil } +var errDeleteInstallerWithAssociatedPolicy = errors.New("Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.") + func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id) if err != nil { + if isMySQLForeignKey(err) { + // Check if the software installer is referenced by a policy automation. + var count int + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM policies WHERE software_installer_id = ?`, id); err != nil { + return ctxerr.Wrapf(ctx, err, "getting reference from policies") + } + if count > 0 { + return ctxerr.Wrap(ctx, errDeleteInstallerWithAssociatedPolicy, "delete software installer") + } + } return ctxerr.Wrap(ctx, err, "delete software installer") } @@ -329,6 +469,75 @@ func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID ui return installID, ctxerr.Wrap(ctx, err, "inserting new install software request") } +func (ds *Datastore) ProcessInstallerUpdateSideEffects(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error { + return ds.withTx(ctx, func(tx sqlx.ExtContext) error { + return ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, installerID, wasMetadataUpdated, wasPackageUpdated) + }) +} + +func (ds *Datastore) runInstallerUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error { + if wasMetadataUpdated || wasPackageUpdated { // cancel pending installs/uninstalls + // TODO make this less naive; this assumes that installs/uninstalls execute and report back immediately + _, err := tx.ExecContext(ctx, `DELETE FROM host_script_results WHERE execution_id IN ( + SELECT execution_id FROM host_software_installs WHERE software_installer_id = ? AND status = 'pending_uninstall' + )`, installerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete pending uninstall scripts") + } + + _, err = tx.ExecContext(ctx, `DELETE FROM host_software_installs + WHERE software_installer_id = ? AND status IN('pending_install', 'pending_uninstall')`, installerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete pending host software installs/uninstalls") + } + } + + if wasPackageUpdated { // hide existing install counts + _, err := tx.ExecContext(ctx, `UPDATE host_software_installs SET removed = TRUE + WHERE software_installer_id = ? AND status IS NOT NULL AND host_deleted_at IS NULL`, installerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "hide existing install counts") + } + } + + return nil +} + +func (ds *Datastore) InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error { + const ( + insertStmt = ` + INSERT INTO host_software_installs + (execution_id, host_id, software_installer_id, user_id, uninstall) + VALUES + (?, ?, ?, ?, 1) + ` + hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?` + ) + + // we need to explicitly do this check here because we can't set a FK constraint on the schema + var hostExists bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &hostExists, hostExistsStmt, hostID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return notFound("Host").WithID(hostID) + } + return ctxerr.Wrap(ctx, err, "checking if host exists") + } + + var userID *uint + if ctxUser := authz.UserFromContext(ctx); ctxUser != nil { + userID = &ctxUser.ID + } + _, err = ds.writer(ctx).ExecContext(ctx, insertStmt, + executionID, + hostID, + softwareInstallerID, + userID, + ) + + return ctxerr.Wrap(ctx, err, "inserting new uninstall software request") +} + func (ds *Datastore) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) { query := fmt.Sprintf(` SELECT @@ -339,26 +548,27 @@ SELECT hsi.host_id AS host_id, st.name AS software_title, st.id AS software_title_id, - COALESCE(%s, '') AS status, + COALESCE(hsi.status, '') AS status, si.filename AS software_package, hsi.user_id AS user_id, hsi.post_install_script_exit_code, hsi.install_script_exit_code, - hsi.self_service, - hsi.host_deleted_at + hsi.self_service, + hsi.host_deleted_at, + hsi.created_at as created_at, + si.user_id AS software_installer_user_id, + si.user_name AS software_installer_user_name, + si.user_email AS software_installer_user_email FROM host_software_installs hsi JOIN software_installers si ON si.id = hsi.software_installer_id JOIN software_titles st ON si.title_id = st.id WHERE hsi.execution_id = :execution_id - `, softwareInstallerHostStatusNamedQuery("hsi", "")) + `) stmt, args, err := sqlx.Named(query, map[string]any{ - "execution_id": resultsUUID, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_pending": fleet.SoftwareInstallerPending, - "software_status_installed": fleet.SoftwareInstallerInstalled, + "execution_id": resultsUUID, }) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build named query for get software install results") @@ -381,13 +591,15 @@ func (ds *Datastore) GetSummaryHostSoftwareInstalls(ctx context.Context, install stmt := fmt.Sprintf(` SELECT - COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending, - COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed, + COALESCE(SUM( IF(status = :software_status_pending_install, 1, 0)), 0) AS pending_install, + COALESCE(SUM( IF(status = :software_status_failed_install, 1, 0)), 0) AS failed_install, + COALESCE(SUM( IF(status = :software_status_pending_uninstall, 1, 0)), 0) AS pending_uninstall, + COALESCE(SUM( IF(status = :software_status_failed_uninstall, 1, 0)), 0) AS failed_uninstall, COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed FROM ( SELECT software_installer_id, - %s + status FROM host_software_installs hsi WHERE @@ -399,14 +611,17 @@ WHERE WHERE software_installer_id = :installer_id AND host_deleted_at IS NULL + AND removed = 0 GROUP BY - host_id)) s`, softwareInstallerHostStatusNamedQuery("hsi", "status")) + host_id)) s`) query, args, err := sqlx.Named(stmt, map[string]interface{}{ - "installer_id": installerID, - "software_status_pending": fleet.SoftwareInstallerPending, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_installed": fleet.SoftwareInstallerInstalled, + "installer_id": installerID, + "software_status_pending_install": fleet.SoftwareInstallPending, + "software_status_failed_install": fleet.SoftwareInstallFailed, + "software_status_pending_uninstall": fleet.SoftwareUninstallPending, + "software_status_failed_uninstall": fleet.SoftwareUninstallFailed, + "software_status_installed": fleet.SoftwareInstalled, }) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get summary host software installs: named query") @@ -421,6 +636,15 @@ WHERE } func (ds *Datastore) vppAppJoin(appID fleet.VPPAppID, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) { + // Since VPP does not have uninstaller yet, we map the generic pending/failed statuses to the install statuses + switch status { + case fleet.SoftwarePending: + status = fleet.SoftwareInstallPending + case fleet.SoftwareFailed: + status = fleet.SoftwareInstallFailed + default: + // no change + } stmt := fmt.Sprintf(`JOIN ( SELECT host_id @@ -445,9 +669,9 @@ WHERE "status": status, "adam_id": appID.AdamID, "platform": appID.Platform, - "software_status_installed": fleet.SoftwareInstallerInstalled, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_pending": fleet.SoftwareInstallerPending, + "software_status_installed": fleet.SoftwareInstalled, + "software_status_failed": fleet.SoftwareInstallFailed, + "software_status_pending": fleet.SoftwareInstallPending, "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, "mdm_status_error": fleet.MDMAppleStatusError, "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, @@ -455,6 +679,21 @@ WHERE } func (ds *Datastore) softwareInstallerJoin(installerID uint, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) { + statusFilter := "hsi.status = :status" + var status2 fleet.SoftwareInstallerStatus + switch status { + case fleet.SoftwarePending: + status = fleet.SoftwareInstallPending + status2 = fleet.SoftwareUninstallPending + case fleet.SoftwareFailed: + status = fleet.SoftwareInstallFailed + status2 = fleet.SoftwareUninstallFailed + default: + // no change + } + if status2 != "" { + statusFilter = "hsi.status IN (:status, :status2)" + } stmt := fmt.Sprintf(`JOIN ( SELECT host_id @@ -468,21 +707,52 @@ WHERE FROM host_software_installs WHERE software_installer_id = :installer_id + AND removed = 0 GROUP BY host_id, software_installer_id) - AND (%s) = :status) hss ON hss.host_id = h.id -`, softwareInstallerHostStatusNamedQuery("hsi", "")) + AND %s) hss ON hss.host_id = h.id +`, statusFilter) return sqlx.Named(stmt, map[string]interface{}{ - "status": status, - "installer_id": installerID, - "software_status_installed": fleet.SoftwareInstallerInstalled, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_pending": fleet.SoftwareInstallerPending, + "status": status, + "status2": status2, + "installer_id": installerID, }) } -func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore) error { +func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { + stmt := fmt.Sprintf(` + SELECT execution_id, hsi.status + FROM host_software_installs hsi + WHERE hsi.id = ( + SELECT + MAX(id) + FROM host_software_installs + WHERE + software_installer_id = :installer_id AND host_id = :host_id + GROUP BY + host_id, software_installer_id) +`) + + stmt, args, err := sqlx.Named(stmt, map[string]interface{}{ + "host_id": hostID, + "installer_id": installerID, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build named query to get host last install data") + } + + var hostLastInstall fleet.HostLastInstallData + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get host last install data") + } + return &hostLastInstall, nil +} + +func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error { if softwareInstallStore == nil { // no-op in this case, possible if not running with a Premium license return nil @@ -494,7 +764,7 @@ func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwa return ctxerr.Wrap(ctx, err, "get list of software installers in use") } - _, err := softwareInstallStore.Cleanup(ctx, storageIDs) + _, err := softwareInstallStore.Cleanup(ctx, storageIDs, removeCreatedBefore) return ctxerr.Wrap(ctx, err, "cleanup unused software installers") } @@ -517,6 +787,16 @@ FROM software_titles WHERE (name, source, browser) IN (%s) ` + + const unsetAllInstallersFromPolicies = ` +UPDATE + policies +SET + software_installer_id = NULL +WHERE + team_id = ? +` + const deleteAllInstallersInTeam = ` DELETE FROM software_installers @@ -524,6 +804,19 @@ WHERE global_or_team_id = ? ` + const unsetInstallersNotInListFromPolicies = ` +UPDATE + policies +SET + software_installer_id = NULL +WHERE + software_installer_id IN ( + SELECT id FROM software_installers + WHERE global_or_team_id = ? AND + title_id NOT IN (?) + ) +` + const deleteInstallersNotInList = ` DELETE FROM software_installers @@ -532,32 +825,57 @@ WHERE title_id NOT IN (?) ` + const checkExistingInstaller = ` +SELECT id, +storage_id != ? is_package_modified, +install_script_content_id != ? OR uninstall_script_content_id != ? OR pre_install_query != ? OR +COALESCE(post_install_script_content_id != ? OR + (post_install_script_content_id IS NULL AND ? IS NOT NULL) OR + (? IS NULL AND post_install_script_content_id IS NOT NULL) +, FALSE) is_metadata_modified FROM software_installers +WHERE global_or_team_id = ? AND title_id IN (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '') +` + const insertNewOrEditedInstaller = ` INSERT INTO software_installers ( team_id, global_or_team_id, storage_id, - filename, + filename, + extension, version, install_script_content_id, + uninstall_script_content_id, pre_install_query, post_install_script_content_id, platform, self_service, - title_id + title_id, + user_id, + user_name, + user_email, + url, + package_ids ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '') + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), + ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ? ) ON DUPLICATE KEY UPDATE install_script_content_id = VALUES(install_script_content_id), + uninstall_script_content_id = VALUES(uninstall_script_content_id), post_install_script_content_id = VALUES(post_install_script_content_id), storage_id = VALUES(storage_id), filename = VALUES(filename), + extension = VALUES(extension), version = VALUES(version), pre_install_query = VALUES(pre_install_query), platform = VALUES(platform), - self_service = VALUES(self_service) + self_service = VALUES(self_service), + user_id = VALUES(user_id), + user_name = VALUES(user_name), + user_email = VALUES(user_email), + url = VALUES(url) ` // use a team id of 0 if no-team @@ -566,12 +884,17 @@ ON DUPLICATE KEY UPDATE globalOrTeamID = *tmID } - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // if no installers are provided, just delete whatever was in // the table if len(installers) == 0 { - _, err := tx.ExecContext(ctx, deleteAllInstallersInTeam, globalOrTeamID) - return ctxerr.Wrap(ctx, err, "delete obsolete software installers") + if _, err := tx.ExecContext(ctx, unsetAllInstallersFromPolicies, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "unset all obsolete installers in policies") + } + if _, err := tx.ExecContext(ctx, deleteAllInstallersInTeam, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete software installers") + } + return nil } var args []any @@ -592,7 +915,15 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "load existing titles") } - stmt, args, err := sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs) + stmt, args, err := sqlx.In(unsetInstallersNotInListFromPolicies, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to unset obsolete installers from policies") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "unset obsolete software installers from policies") + } + + stmt, args, err = sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs) if err != nil { return ctxerr.Wrap(ctx, err, "build statement to delete obsolete installers") } @@ -607,6 +938,12 @@ ON DUPLICATE KEY UPDATE } installScriptID, _ := isRes.LastInsertId() + uisRes, err := insertScriptContents(ctx, tx, installer.UninstallScript) + if err != nil { + return ctxerr.Wrapf(ctx, err, "inserting uninstall script contents for software installer with name %q", installer.Filename) + } + uninstallScriptID, _ := uisRes.LastInsertId() + var postInstallScriptID *int64 if installer.PostInstallScript != "" { pisRes, err := insertScriptContents(ctx, tx, installer.PostInstallScript) @@ -618,39 +955,106 @@ ON DUPLICATE KEY UPDATE postInstallScriptID = &insertID } + wasUpdatedArgs := []interface{}{ + // package update + installer.StorageID, + // metadata update + installScriptID, + uninstallScriptID, + installer.PreInstallQuery, + postInstallScriptID, + postInstallScriptID, + postInstallScriptID, + // WHERE clause + globalOrTeamID, + installer.Title, + installer.Source, + } + + // pull existing installer state if it exists so we can diff for side effects post-update + type existingInstallerUpdateCheckResult struct { + InstallerID uint `db:"id"` + IsPackageModified bool `db:"is_package_modified"` + IsMetadataModified bool `db:"is_metadata_modified"` + } + var existing []existingInstallerUpdateCheckResult + err = sqlx.SelectContext(ctx, tx, &existing, checkExistingInstaller, wasUpdatedArgs...) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrapf(ctx, err, "checking for existing installer with name %q", installer.Filename) + } + } + args := []interface{}{ tmID, globalOrTeamID, installer.StorageID, installer.Filename, + installer.Extension, installer.Version, installScriptID, + uninstallScriptID, installer.PreInstallQuery, postInstallScriptID, installer.Platform, installer.SelfService, installer.Title, installer.Source, + installer.UserID, + installer.UserID, + installer.UserID, + installer.URL, + strings.Join(installer.PackageIDs, ","), + } + upsertQuery := insertNewOrEditedInstaller + if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package + upsertQuery = fmt.Sprintf("%s, uploaded_at = NOW()", upsertQuery) } - if _, err := tx.ExecContext(ctx, insertNewOrEditedInstaller, args...); err != nil { + if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil { return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename) } + + // perform side effects if this was an update + if len(existing) > 0 { + if err := ds.runInstallerUpdateSideEffectsInTransaction( + ctx, + tx, + existing[0].InstallerID, + existing[0].IsMetadataModified, + existing[0].IsPackageModified, + ); err != nil { + return ctxerr.Wrapf(ctx, err, "processing installer with name %q", installer.Filename) + } + } } + return nil - }) + }); err != nil { + return err + } + return nil } func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) { if fleet.IsLinux(hostPlatform) { hostPlatform = "linux" } - stmt := `SELECT 1 FROM software_installers WHERE self_service = 1 AND platform = ? AND global_or_team_id = ?` + stmt := `SELECT 1 + WHERE EXISTS ( + SELECT 1 + FROM software_installers + WHERE self_service = 1 AND platform = ? AND global_or_team_id = ? + ) OR EXISTS ( + SELECT 1 + FROM vpp_apps_teams + WHERE self_service = 1 AND platform = ? AND global_or_team_id = ? + )` var globalOrTeamID uint if hostTeamID != nil { globalOrTeamID = *hostTeamID } - args := []interface{}{hostPlatform, globalOrTeamID} + args := []interface{}{hostPlatform, globalOrTeamID, hostPlatform, globalOrTeamID} var hasInstallers bool err := sqlx.GetContext(ctx, ds.reader(ctx), &hasInstallers, stmt, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { @@ -658,3 +1062,79 @@ func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostP } return hasInstallers, nil } + +func (ds *Datastore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error) { + stmt := ` + SELECT name + FROM software_titles st + INNER JOIN software_installers si ON si.title_id = st.id + INNER JOIN host_software_installs hsi ON hsi.software_installer_id = si.id + WHERE hsi.execution_id = ? + ` + var name string + err := sqlx.GetContext(ctx, ds.reader(ctx), &name, stmt, executionID) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "get software title name from execution ID") + } + return name, nil +} + +func (ds *Datastore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + query := ` + SELECT id, storage_id FROM software_installers WHERE package_ids = '' + ` + type result struct { + ID uint `db:"id"` + StorageID string `db:"storage_id"` + } + + var results []result + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installers without package ID") + } + if len(results) == 0 { + return nil, nil + } + idMap := make(map[uint]string, len(results)) + for _, r := range results { + idMap[r.ID] = r.StorageID + } + return idMap, nil +} + +func (ds *Datastore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, + payload fleet.UploadSoftwareInstallerPayload, +) error { + uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID") + } + query := ` + UPDATE software_installers + SET package_ids = ?, uninstall_script_content_id = ?, extension = ? + WHERE id = ? + ` + _, err = ds.writer(ctx).ExecContext(ctx, query, strings.Join(payload.PackageIDs, ","), uninstallScriptID, payload.Extension, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "update software installer without package ID") + } + return nil +} + +func (ds *Datastore) GetSoftwareInstallers(ctx context.Context, teamID uint) ([]fleet.SoftwarePackageResponse, error) { + const loadInsertedSoftwareInstallers = ` +SELECT + team_id, + title_id, + url +FROM + software_installers +WHERE global_or_team_id = ? +` + var softwarePackages []fleet.SoftwarePackageResponse + // Using ds.writer(ctx) on purpose because this method is to be called after applying software. + if err := sqlx.SelectContext(ctx, ds.writer(ctx), &softwarePackages, loadInsertedSoftwareInstallers, teamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installers") + } + return softwarePackages, nil +} diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index f2630770a7..178b858071 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -13,6 +13,7 @@ import ( "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/assert" "github.com/stretchr/testify/require" ) @@ -31,6 +32,8 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, + {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy}, + {"GetHostLastInstallData", testGetHostLastInstallData}, } for _, c := range cases { @@ -46,17 +49,20 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello", PreInstallQuery: "SELECT 1", PostInstallScript: "world", + UninstallScript: "goodbye", InstallerFile: bytes.NewReader([]byte("hello")), StorageID: "storage1", Filename: "file1", Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -70,6 +76,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Title: "file2", Version: "2.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -84,6 +91,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Version: "3.0", Source: "apps", SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) @@ -112,7 +120,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ HostID: host2.ID, InstallUUID: hostInstall5, - PreInstallConditionOutput: ptr.String("output"), + PreInstallConditionOutput: ptr.String(""), // pre-install query did not return results, so install failed }) require.NoError(t, err) @@ -139,6 +147,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { require.Equal(t, installerID1, exec1.InstallerID) require.Equal(t, "SELECT 1", exec1.PreInstallCondition) require.False(t, exec1.SelfService) + assert.Equal(t, "goodbye", exec1.UninstallScript) hostInstall6, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID3, true) require.NoError(t, err) @@ -169,6 +178,8 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + cases := map[string]*uint{ "no team": nil, "team": &team.ID, @@ -188,6 +199,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { InstallScript: "echo", TeamID: teamID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -202,24 +214,128 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { _, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, false) require.ErrorAs(t, err, &nfe) - // successful insert - host, err := ds.NewHost(ctx, &fleet.Host{ - Hostname: "macos-test" + tc, - OsqueryHostID: ptr.String("osquery-macos" + tc), - NodeKey: ptr.String("node-key-macos" + tc), + // Host with software install pending + tag := "-pending_install" + hostPendingInstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), UUID: uuid.NewString(), Platform: "darwin", TeamID: teamID, }) require.NoError(t, err) - _, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID, false) + _, err = ds.InsertSoftwareInstallRequest(ctx, hostPendingInstall.ID, si.InstallerID, false) require.NoError(t, err) - // list hosts with software install requests + // Host with software install failed + tag = "-failed_install" + hostFailedInstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), + UUID: uuid.NewString(), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + _, err = ds.InsertSoftwareInstallRequest(ctx, hostFailedInstall.ID, si.InstallerID, false) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, ` + UPDATE host_software_installs SET install_script_exit_code = 1 WHERE host_id = ? AND software_installer_id = ?`, + hostFailedInstall.ID, si.InstallerID) + require.NoError(t, err) + return nil + }) + + // Host with software install successful + tag = "-installed" + hostInstalled, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), + UUID: uuid.NewString(), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + _, err = ds.InsertSoftwareInstallRequest(ctx, hostInstalled.ID, si.InstallerID, false) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, ` + UPDATE host_software_installs SET install_script_exit_code = 0 WHERE host_id = ? AND software_installer_id = ?`, + hostInstalled.ID, si.InstallerID) + require.NoError(t, err) + return nil + }) + + // Host with pending uninstall + tag = "-pending_uninstall" + hostPendingUninstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), + UUID: uuid.NewString(), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostPendingUninstall.ID, si.InstallerID) + require.NoError(t, err) + + // Host with failed uninstall + tag = "-failed_uninstall" + hostFailedUninstall, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), + UUID: uuid.NewString(), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostFailedUninstall.ID, si.InstallerID) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, ` + UPDATE host_software_installs SET uninstall_script_exit_code = 1 WHERE host_id = ? AND software_installer_id = ?`, + hostFailedUninstall.ID, si.InstallerID) + require.NoError(t, err) + return nil + }) + + // Host with successful uninstall + tag = "-uninstalled" + hostUninstalled, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-test" + tag + tc, + OsqueryHostID: ptr.String("osquery-macos" + tag + tc), + NodeKey: ptr.String("node-key-macos" + tag + tc), + UUID: uuid.NewString(), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostUninstalled.ID, si.InstallerID) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, ` + UPDATE host_software_installs SET uninstall_script_exit_code = 0 WHERE host_id = ? AND software_installer_id = ?`, + hostUninstalled.ID, si.InstallerID) + require.NoError(t, err) + return nil + }) + + // Uninstall request with unknown host + err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, 99999, si.InstallerID) + assert.ErrorContains(t, err, "Host") + userTeamFilter := fleet.TeamFilter{ User: &fleet.User{GlobalRole: ptr.String("admin")}, } - expectStatus := fleet.SoftwareInstallerPending + + // list hosts with software install pending requests + expectStatus := fleet.SoftwareInstallPending hosts, err := ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ ListOptions: fleet.ListOptions{PerPage: 100}, SoftwareTitleIDFilter: installerMeta.TitleID, @@ -228,15 +344,98 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.Len(t, hosts, 1) - require.Equal(t, host.ID, hosts[0].ID) + require.Equal(t, hostPendingInstall.ID, hosts[0].ID) + + // list hosts with all pending requests + expectStatus = fleet.SoftwarePending + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 2) + assert.ElementsMatch(t, []uint{hostPendingInstall.ID, hostPendingUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID}) + + // list hosts with software install failed requests + expectStatus = fleet.SoftwareInstallFailed + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 1) + assert.ElementsMatch(t, []uint{hostFailedInstall.ID}, []uint{hosts[0].ID}) + + // list hosts with all failed requests + expectStatus = fleet.SoftwareFailed + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 2) + assert.ElementsMatch(t, []uint{hostFailedInstall.ID, hostFailedUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID}) + + // list hosts with software installed + expectStatus = fleet.SoftwareInstalled + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 1) + assert.ElementsMatch(t, []uint{hostInstalled.ID}, []uint{hosts[0].ID}) + + // list hosts with pending software uninstall requests + expectStatus = fleet.SoftwareUninstallPending + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 1) + assert.ElementsMatch(t, []uint{hostPendingUninstall.ID}, []uint{hosts[0].ID}) + + // list hosts with failed software uninstall requests + expectStatus = fleet.SoftwareUninstallFailed + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + SoftwareStatusFilter: &expectStatus, + TeamFilter: teamID, + }) + require.NoError(t, err) + require.Len(t, hosts, 1) + assert.ElementsMatch(t, []uint{hostFailedUninstall.ID}, []uint{hosts[0].ID}) + + // list all hosts with the software title that shows up in host_software (after fleetd software query is run) + hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + SoftwareTitleIDFilter: installerMeta.TitleID, + TeamFilter: teamID, + }) + require.NoError(t, err) + assert.Empty(t, hosts) // get software title includes status summary, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerMeta.InstallerID) require.NoError(t, err) require.Equal(t, fleet.SoftwareInstallerStatusSummary{ - Installed: 0, - Pending: 1, - Failed: 0, + Installed: 1, + PendingInstall: 1, + FailedInstall: 1, + PendingUninstall: 1, + FailedUninstall: 1, }, *summary) }) } @@ -249,6 +448,8 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { require.NoError(t, err) teamID := team.ID + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + for _, tc := range []struct { name string expectedStatus fleet.SoftwareInstallerStatus @@ -260,27 +461,27 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { }{ { name: "pending install", - expectedStatus: fleet.SoftwareInstallerPending, + expectedStatus: fleet.SoftwareInstallPending, postInstallScriptOutput: ptr.String("post install output"), installScriptOutput: ptr.String("install output"), }, { name: "failing install post install script", - expectedStatus: fleet.SoftwareInstallerFailed, + expectedStatus: fleet.SoftwareInstallFailed, postInstallScriptEC: ptr.Int(1), postInstallScriptOutput: ptr.String("post install output"), installScriptOutput: ptr.String("install output"), }, { name: "failing install install script", - expectedStatus: fleet.SoftwareInstallerFailed, + expectedStatus: fleet.SoftwareInstallFailed, installScriptEC: ptr.Int(1), postInstallScriptOutput: ptr.String("post install output"), installScriptOutput: ptr.String("install output"), }, { name: "failing install pre install query", - expectedStatus: fleet.SoftwareInstallerFailed, + expectedStatus: fleet.SoftwareInstallFailed, preInstallQueryOutput: ptr.String(""), postInstallScriptOutput: ptr.String("post install output"), installScriptOutput: ptr.String("install output"), @@ -295,6 +496,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { InstallScript: "echo " + tc.name, TeamID: &teamID, Filename: swFilename, + UserID: user1.ID, }) require.NoError(t, err) host, err := ds.NewHost(ctx, &fleet.Host{ @@ -342,6 +544,8 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { store, err := filesystem.NewSoftwareInstallerStore(dir) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + assertExisting := func(want []string) { dirEnts, err := os.ReadDir(filepath.Join(dir, "software-installers")) require.NoError(t, err) @@ -355,7 +559,7 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { } // cleanup an empty store - err = ds.CleanupUnusedSoftwareInstallers(ctx, store) + err = ds.CleanupUnusedSoftwareInstallers(ctx, store, time.Now()) require.NoError(t, err) assertExisting(nil) @@ -373,11 +577,12 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "installer0", Title: "ins0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) assertExisting([]string{ins0}) - err = ds.CleanupUnusedSoftwareInstallers(ctx, store) + err = ds.CleanupUnusedSoftwareInstallers(ctx, store, time.Now()) require.NoError(t, err) assertExisting([]string{ins0}) @@ -385,7 +590,13 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { err = ds.DeleteSoftwareInstaller(ctx, swi) require.NoError(t, err) - err = ds.CleanupUnusedSoftwareInstallers(ctx, store) + // would clean up, but not created before 1m ago + err = ds.CleanupUnusedSoftwareInstallers(ctx, store, time.Now().Add(-time.Minute)) + require.NoError(t, err) + assertExisting([]string{ins0}) + + // do actual cleanup + err = ds.CleanupUnusedSoftwareInstallers(ctx, store, time.Now().Add(time.Minute)) require.NoError(t, err) assertExisting(nil) } @@ -397,6 +608,8 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // TODO(roberto): perform better assertions, we should have evertything // to check that the actual values of everything match. assertSoftware := func(wantTitles []fleet.SoftwareTitle) { @@ -419,9 +632,15 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { // batch set with everything empty err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, nil) require.NoError(t, err) + softwareInstallers, err := ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware(nil) err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) require.NoError(t, err) + softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware(nil) // add a single installer @@ -436,8 +655,18 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "foo", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example.com", }}) require.NoError(t, err) + softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, softwareInstallers, 1) + require.NotNil(t, softwareInstallers[0].TeamID) + require.Equal(t, team.ID, *softwareInstallers[0].TeamID) + require.NotNil(t, softwareInstallers[0].TitleID) + require.Equal(t, "https://example.com", softwareInstallers[0].URL) assertSoftware([]fleet.SoftwareTitle{ {Name: ins0, Source: "apps", Browser: ""}, }) @@ -455,6 +684,9 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example.com", }, { InstallScript: "install", @@ -466,9 +698,23 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, + Platform: "darwin", + URL: "https://example2.com", }, }) require.NoError(t, err) + softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, softwareInstallers, 2) + require.NotNil(t, softwareInstallers[0].TitleID) + require.NotNil(t, softwareInstallers[0].TeamID) + require.Equal(t, team.ID, *softwareInstallers[0].TeamID) + require.Equal(t, "https://example.com", softwareInstallers[0].URL) + require.NotNil(t, softwareInstallers[1].TitleID) + require.NotNil(t, softwareInstallers[1].TeamID) + require.Equal(t, team.ID, *softwareInstallers[1].TeamID) + require.Equal(t, "https://example2.com", softwareInstallers[1].URL) assertSoftware([]fleet.SoftwareTitle{ {Name: ins0, Source: "apps", Browser: ""}, {Name: ins1, Source: "apps", Browser: ""}, @@ -486,9 +732,16 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, }, }) require.NoError(t, err) + softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, softwareInstallers, 1) + require.NotNil(t, softwareInstallers[0].TitleID) + require.NotNil(t, softwareInstallers[0].TeamID) + require.Empty(t, softwareInstallers[0].URL) assertSoftware([]fleet.SoftwareTitle{ {Name: ins1, Source: "apps", Browser: ""}, }) @@ -496,6 +749,9 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { // remove everything err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) require.NoError(t, err) + softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware([]fleet.SoftwareTitle{}) } @@ -503,6 +759,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor ctx := context.Background() team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foo", @@ -512,10 +769,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor PreInstallQuery: "SELECT 1", TeamID: &team.ID, Filename: "foo.pkg", + Platform: "darwin", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) require.NoError(t, err) + require.Equal(t, "darwin", installerMeta.Platform) metaByTeamAndTitle, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, *installerMeta.TitleID, true) require.NoError(t, err) @@ -530,6 +790,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor InstallScript: "echo install", TeamID: &team.ID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -548,6 +809,9 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + test.CreateInsertGlobalVPPToken(t, ds) const platform = "linux" // No installers @@ -567,6 +831,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo.pkg", Platform: platform, SelfService: false, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -585,6 +850,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo2.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -594,6 +860,26 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, hasSelfService) + // Create a non self-service VPP for global/linux (not truly possible as VPP is Apple but for testing) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: platform}}, Name: "vpp1", BundleIdentifier: "com.app.vpp1"}, nil) + require.NoError(t, err) + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) + require.NoError(t, err) + assert.False(t, hasSelfService) + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, &team.ID) + require.NoError(t, err) + assert.True(t, hasSelfService) + + // Create a self-service VPP for global/linux (not truly possible as VPP is Apple but for testing) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: platform}, SelfService: true}, Name: "vpp2", BundleIdentifier: "com.app.vpp2"}, nil) + require.NoError(t, err) + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) + require.NoError(t, err) + assert.True(t, hasSelfService) + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, &team.ID) + require.NoError(t, err) + assert.True(t, hasSelfService) + // Create a global self-service installer _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foo global", @@ -603,6 +889,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo global.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil) @@ -612,11 +899,202 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, hasSelfService) - // Check another platform + // Create a self-service VPP for team/darwin + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", BundleIdentifier: "com.app.vpp3"}, &team.ID) + require.NoError(t, err) + // Check darwin hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "darwin", nil) require.NoError(t, err) assert.False(t, hasSelfService) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "darwin", &team.ID) require.NoError(t, err) - assert.False(t, hasSelfService) + assert.True(t, hasSelfService) +} + +func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) { + ctx := context.Background() + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + softwareInstallerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins0", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: &softwareInstallerID, + }) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.NoError(t, err) +} + +func testGetHostLastInstallData(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithTeamID(team1.ID)) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithTeamID(team1.ID)) + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + softwareInstallerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins1", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + softwareInstallerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install2", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer2.pkg", + Title: "ins2", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + // No installations on host1 yet. + host1LastInstall, err := ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Install installer.pkg on host1. + installUUID1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID1) + + // Last installation should be pending. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + + // Set result of last installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID1, + + InstallScriptExitCode: ptr.Int(0), + }) + require.NoError(t, err) + + // Last installation should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstalled, *host1LastInstall.Status) + + // Install installer2.pkg on host1. + installUUID2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID2, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID2) + + // Last installation for installer1.pkg should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstalled, *host1LastInstall.Status) + // Last installation for installer2.pkg should be "pending". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID2) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID2, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + + // Perform another installation of installer1.pkg. + installUUID3, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID3) + + // Last installation for installer1.pkg should be "pending" again. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + + // Set result of last installer1.pkg installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID3, + + InstallScriptExitCode: ptr.Int(1), + }) + require.NoError(t, err) + + // Last installation for installer1.pkg should be "failed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallFailed, *host1LastInstall.Status) + + // No installations on host2. + host2LastInstall, err := ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host2LastInstall) + host2LastInstall, err = ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID2) + require.NoError(t, err) + require.Nil(t, host2LastInstall) } diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index b069975f9c..0fabe761f7 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -1,6 +1,7 @@ package mysql import ( + "bytes" "context" "database/sql" "encoding/hex" @@ -66,6 +67,9 @@ func TestSoftware(t *testing.T) { {"ListHostSoftware", testListHostSoftware}, {"ListIOSHostSoftware", testListIOSHostSoftware}, {"SetHostSoftwareInstallResult", testSetHostSoftwareInstallResult}, + {"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam}, + {"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers}, + {"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1954,11 +1958,14 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ SoftwareID: host.Software[0].ID, CVE: "cve-1", }, fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -1997,9 +2004,12 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -2563,9 +2573,9 @@ func testDeleteOutOfDateVulnerabilities(t *testing.T, ds *Datastore) { require.NoError(t, err) // This should update the 'updated_at' timestamp. - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) require.NoError(t, err) - require.False(t, inserted) + assert.True(t, insertedOrUpdated) err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, 2*time.Hour) require.NoError(t, err) @@ -3157,7 +3167,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin")) nanoEnroll(t, ds, host, false) otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("linux")) - opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}} user, err := ds.NewUser(ctx, &fleet.User{ Password: []byte("p4ssw0rd.123"), @@ -3167,6 +3177,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus { return &s } @@ -3185,14 +3197,14 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.Equal(t, &fleet.PaginationMetadata{}, meta) // available for install only works too - opts.AvailableForInstall = true + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) assert.Empty(t, sw) assert.Equal(t, &fleet.PaginationMetadata{}, meta) // self-service only works too - opts.AvailableForInstall = false + opts.OnlyAvailableForInstall = false opts.SelfServiceOnly = true opts.IncludeAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) @@ -3214,6 +3226,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { {Name: "c", Version: "0.0.4", Source: "deb_packages"}, {Name: "c", Version: "0.0.5", Source: "deb_packages"}, {Name: "d", Version: "0.0.6", Source: "deb_packages"}, + {Name: "e", Version: "0.0.2", Source: "deb_packages"}, // not vulnerable version } byNSV := map[string]fleet.Software{} for _, s := range software { @@ -3251,9 +3264,21 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { otherSoftware := []fleet.Software{ {Name: "a", Version: "0.0.7", Source: "chrome_extensions"}, {Name: "f", Version: "0.0.8", Source: "chrome_extensions"}, + {Name: "e", Version: "0.0.1", Source: "deb_packages"}, // vulnerable version } - _, err = ds.UpdateHostSoftware(ctx, otherHost.ID, otherSoftware) + otherSoftwareByNSV := map[string]fleet.Software{} + for _, s := range otherSoftware { + otherSoftwareByNSV[s.Name+s.Source+s.Version] = s + } + otherMutationResults, err := ds.UpdateHostSoftware(ctx, otherHost.ID, otherSoftware) require.NoError(t, err) + for _, m := range otherMutationResults.Inserted { + s, ok := otherSoftwareByNSV[m.Name+m.Source+m.Version] + require.True(t, ok) + s.ID = m.ID + otherSoftwareByNSV[s.Name+s.Source+s.Version] = s + } + require.NoError(t, ds.LoadHostSoftware(ctx, otherHost, false)) // shorthand keys for expected software a1 := software[0].Name + software[0].Source + software[0].Version @@ -3262,6 +3287,10 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { c1 := software[3].Name + software[3].Source + software[3].Version c2 := software[4].Name + software[4].Source + software[4].Version d := software[5].Name + software[5].Source + software[5].Version + e2 := software[6].Name + software[6].Source + software[6].Version + + // shorthand keys for other software + e1 := otherSoftware[2].Name + otherSoftware[2].Source + otherSoftware[2].Version // add some vulnerabilities and installed paths vulns := []fleet.SoftwareVulnerability{ @@ -3269,6 +3298,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { {SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0002"}, {SoftwareID: byNSV[a1].ID, CVE: "CVE-a-0003"}, {SoftwareID: byNSV[b].ID, CVE: "CVE-b-0001"}, + {SoftwareID: otherSoftwareByNSV[e1].ID, CVE: "CVE-e-0001"}, } for _, v := range vulns { _, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource) @@ -3308,14 +3338,33 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { byNSV[d].Name + byNSV[d].Source: {Name: byNSV[d].Name, Source: byNSV[d].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[d].Version, InstalledPaths: []string{installPaths[5]}}, }}, + byNSV[e2].Name + byNSV[e2].Source: {Name: byNSV[e2].Name, Source: byNSV[e2].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ + {Version: byNSV[e2].Version, InstalledPaths: []string{installPaths[6]}}, + }}, } compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, expectOmitted ...string) { - require.Len(t, got, len(expected)-len(expectOmitted)) + gotToString := func() string { + var builder strings.Builder + builder.WriteString("Got:\n") + for _, g := range got { + builder.WriteString(fmt.Sprintf("%+v\n", g)) + } + return builder.String() + } + require.Len(t, got, len(expected)-len(expectOmitted), gotToString()) prev := "" for _, g := range got { + + for _, omit := range expectOmitted { + if g.Name+g.Source == omit { + t.Errorf("Did not expect %s in results", omit) + continue + } + } + e, ok := expected[g.Name+g.Source] - require.True(t, ok) + require.True(t, ok, "unexpected software %s%s", g.Name, g.Source) require.Equal(t, e.Name, g.Name) require.Equal(t, e.Source, g.Source) if e.SoftwarePackage != nil { @@ -3329,6 +3378,10 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.Equal(t, e.SoftwarePackage.LastInstall.InstallUUID, g.SoftwarePackage.LastInstall.InstallUUID) require.NotNil(t, g.SoftwarePackage.LastInstall.InstalledAt) } + if e.SoftwarePackage.LastUninstall != nil { + assert.Equal(t, e.SoftwarePackage.LastUninstall.ExecutionID, g.SoftwarePackage.LastUninstall.ExecutionID) + assert.NotNil(t, g.SoftwarePackage.LastUninstall.UninstalledAt) + } } if e.AppStoreApp != nil { @@ -3375,30 +3428,31 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { opts.IncludeAvailableForInstall = false sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: 6}, meta) compareResults(expected, sw, true) opts.VulnerableOnly = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta) - compareResults(expected, sw, true, byNSV[a2].Name+byNSV[a2].Source, byNSV[c1].Name+byNSV[c1].Source, byNSV[d].Name+byNSV[d].Source) + compareResults(expected, sw, true, byNSV[a2].Name+byNSV[a2].Source, byNSV[c1].Name+byNSV[c1].Source, byNSV[d].Name+byNSV[d].Source, byNSV[e2].Name+byNSV[e2].Source) opts.VulnerableOnly = false // No software that is available for install - opts.AvailableForInstall = true + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) assert.Empty(t, sw) assert.Equal(t, &fleet.PaginationMetadata{}, meta) - opts.AvailableForInstall = false + opts.OnlyAvailableForInstall = false // create some Fleet installers and map them to a software title, // including one for a team tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) require.NoError(t, err) - var swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm uint + const numberOfSoftwareInstallers = 8 + var swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm, swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled uint var otherHostI1UUID, otherHostI2UUID string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { // keep title id of software B, will use it to associate an installer with it @@ -3416,10 +3470,19 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } scriptContentID, _ := res.LastInsertId() + // create the uninstall script content (same for all installers, doesn't matter) + uninstallScript := `echo 'bar'` + resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, + uninstallScript, uninstallScript) + if err != nil { + return err + } + uninstallScriptContentID, _ := resUninstall.LastInsertId() + // create software titles for all but swi1Pending (will be linked to // existing software title b) var titleIDs []uint - for i := 0; i < 4; i++ { + for i := 0; i < numberOfSoftwareInstallers-1; i++ { res, err := q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES (?, 'apps')`, fmt.Sprintf("i%d", i)) if err != nil { return err @@ -3429,7 +3492,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } var swiIDs []uint - for i := 0; i < 5; i++ { + for i := 0; i < numberOfSoftwareInstallers; i++ { var ( titleID uint teamID *uint @@ -3446,10 +3509,12 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } res, err := q.ExecContext(ctx, ` INSERT INTO software_installers - (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform, self_service) + (team_id, global_or_team_id, title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id, platform, self_service) VALUES - (?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, - teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "darwin", i < 2) + (?, ?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, + teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), "pkg", fmt.Sprintf("v%d.0.0", i), scriptContentID, + uninstallScriptContentID, + hex.EncodeToString([]byte("test")), "darwin", i < 2) if err != nil { return err } @@ -3457,7 +3522,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { swiIDs = append(swiIDs, uint(id)) } // sw1Pending and swi2Installed are self-service installers - swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4] + swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm, + swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4], swiIDs[5], swiIDs[6], swiIDs[7] // create the results for the host @@ -3506,21 +3572,31 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // swi5 is for another team _ = swi5Tm - // add another installer for a different platform, should be always omitted - res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('windows-title', 'programs')`) - if err != nil { - return err - } - lid, _ := res.LastInsertId() + // swi6 has been installed, and is pending uninstall _, err = q.ExecContext(ctx, ` - INSERT INTO software_installers - (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform) - VALUES - (?, ?, ?, ?, ?, ?, unhex(?), ?)`, - nil, 0, lid, "windows-installer-6.msi", "v6.0.0", scriptContentID, hex.EncodeToString([]byte("test")), "windows") - if err != nil { - return err - } + INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, pre_install_query_output, install_script_exit_code, post_install_script_exit_code) + VALUES (?, ?, ?, ?, ?, ?)`, + "uuid6-pre", host.ID, swi6PendingUninstall, "ok", 0, 0) + require.NoError(t, err) + _, err = q.ExecContext(ctx, ` + INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall) + VALUES (?, ?, ?, ?)`, + "uuid6", host.ID, swi6PendingUninstall, 1) + require.NoError(t, err) + + // swi7 is failed uninstall + _, err = q.ExecContext(ctx, ` + INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall, uninstall_script_exit_code) + VALUES (?, ?, ?, ?, ?)`, + "uuid7", host.ID, swi7FailedUninstall, 1, 1) + require.NoError(t, err) + + // swi8 is successful uninstall + _, err = q.ExecContext(ctx, ` + INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall, uninstall_script_exit_code) + VALUES (?, ?, ?, ?, ?)`, + "uuid8", host.ID, swi8Uninstalled, 1, 0) + require.NoError(t, err) return nil }) @@ -3529,7 +3605,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{ Name: "b", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), + Status: expectStatus(fleet.SoftwareInstallPending), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}}, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, @@ -3538,7 +3614,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { i0 := fleet.HostSoftwareWithInstaller{ Name: "i0", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerInstalled), + Status: expectStatus(fleet.SoftwareInstalled), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-1.pkg", Version: "v1.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"}}, } expected[i0.Name+i0.Source] = i0 @@ -3546,16 +3622,50 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { i1 := fleet.HostSoftwareWithInstaller{ Name: "i1", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerFailed), + Status: expectStatus(fleet.SoftwareInstallFailed), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"}}, } expected[i1.Name+i1.Source] = i1 + i4 := fleet.HostSoftwareWithInstaller{ + Name: "i4", + Source: "apps", + Status: expectStatus(fleet.SoftwareUninstallPending), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "installer-5.pkg", Version: "v5.0.0", SelfService: ptr.Bool(false), + LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid6-pre"}, + LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid6"}, + }, + } + expected[i4.Name+i4.Source] = i4 + + i5 := fleet.HostSoftwareWithInstaller{ + Name: "i5", + Source: "apps", + Status: expectStatus(fleet.SoftwareUninstallFailed), + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "installer-6.pkg", Version: "v6.0.0", SelfService: ptr.Bool(false), + LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid7"}, + }, + } + expected[i5.Name+i5.Source] = i5 + + i6 := fleet.HostSoftwareWithInstaller{ + Name: "i6", + Source: "apps", + Status: nil, + SoftwarePackage: &fleet.SoftwarePackageOrApp{ + Name: "installer-7.pkg", Version: "v7.0.0", SelfService: ptr.Bool(false), + LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid8"}, + }, + } + expected[i6.Name+i6.Source] = i6 + // request without available software opts.IncludeAvailableForInstall = false sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta) compareResults(expected, sw, true) // request with available software @@ -3579,18 +3689,24 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { opts.ListOptions.PerPage = 20 sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) - // request with available software only + // request with available software only (attempted to install and never attempted to install) expectedAvailableOnly := map[string]fleet.HostSoftwareWithInstaller{} + expectedAvailableOnly[byNSV[b].Name+byNSV[b].Source] = expected[byNSV[b].Name+byNSV[b].Source] + expectedAvailableOnly[i0.Name+i0.Source] = i0 + expectedAvailableOnly[i1.Name+i1.Source] = i1 expectedAvailableOnly[i2.Name+i2.Source] = i2 - opts.AvailableForInstall = true + expectedAvailableOnly[i4.Name+i4.Source] = i4 + expectedAvailableOnly[i5.Name+i5.Source] = i5 + expectedAvailableOnly[i6.Name+i6.Source] = i6 + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - assert.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta) + assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta) compareResults(expectedAvailableOnly, sw, true) - opts.AvailableForInstall = false + opts.OnlyAvailableForInstall = false // request in descending order opts.ListOptions.OrderDirection = fleet.OrderDescending @@ -3598,7 +3714,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { opts.IncludeAvailableForInstall = false sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta) compareResults(expected, sw, false, i2.Name+i2.Source, i3.Name+i3.Source) opts.ListOptions.OrderDirection = fleet.OrderAscending opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending @@ -3627,7 +3743,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{ Name: "b", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerFailed), + Status: expectStatus(fleet.SoftwareInstallFailed), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}}, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}}, @@ -3636,22 +3752,24 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { expected[i1.Name+i1.Source] = fleet.HostSoftwareWithInstaller{ Name: "i1", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), + Status: expectStatus(fleet.SoftwareInstallPending), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"}}, } + expectedAvailableOnly[byNSV[b].Name+byNSV[b].Source] = expected[byNSV[b].Name+byNSV[b].Source] + expectedAvailableOnly[i1.Name+i1.Source] = expected[i1.Name+i1.Source] // request without available software opts.IncludeAvailableForInstall = false sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta) compareResults(expected, sw, true, i2.Name+i2.Source, i3.Name+i3.Source) - // request with available software) + // request with available software opts.IncludeAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) // create a new host in the team, with no software @@ -3706,25 +3824,36 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and two for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) + // create vpp3 app that allows self-service va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) vpp2, vpp3 := va2.AdamID, va3.AdamID @@ -3747,44 +3876,46 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { expected["vpp1apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp1", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerInstalled), - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}}, + Status: expectStatus(fleet.SoftwareInstalled), + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}}, } expected["vpp2apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp2", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}}, + Status: expectStatus(fleet.SoftwareInstallPending), + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}}, } opts.IncludeAvailableForInstall = false opts.ListOptions.MatchQuery = "" sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 9}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta) compareResults(expected, sw, true, i3.Name+i3.Source, i2.Name+i2.Source) // i3 is for team, i2 is available (excluded) expected["vpp3apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp3", Source: "apps", Status: nil, - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, SelfService: ptr.Bool(true)}, } + expectedAvailableOnly["vpp1apps"] = expected["vpp1apps"] + expectedAvailableOnly["vpp2apps"] = expected["vpp2apps"] expectedAvailableOnly["vpp3apps"] = expected["vpp3apps"] opts.IncludeAvailableForInstall = true opts.ListOptions.PerPage = 20 sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 11}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) // i3 is for team // Available for install only - opts.AvailableForInstall = true + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) - assert.Equal(t, &fleet.PaginationMetadata{TotalResults: 2}, meta) + assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta) compareResults(expectedAvailableOnly, sw, true) - opts.AvailableForInstall = false + opts.OnlyAvailableForInstall = false // team host sees available i3 and pending vpp1 opts.IncludeAvailableForInstall = true @@ -3796,8 +3927,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { "vpp1apps": { Name: "vpp1", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1TmCmdUUID}}, + Status: expectStatus(fleet.SoftwareInstallPending), + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1TmCmdUUID}}, }, }, sw, true) @@ -3805,7 +3936,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { opts.IncludeAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, otherHost, opts) require.NoError(t, err) - require.Equal(t, &fleet.PaginationMetadata{TotalResults: 4}, meta) + require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta) expectedOther := map[string]fleet.HostSoftwareWithInstaller{ otherSoftware[0].Name + otherSoftware[0].Source: {Name: otherSoftware[0].Name, Source: otherSoftware[0].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ @@ -3814,16 +3945,19 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { otherSoftware[1].Name + otherSoftware[1].Source: {Name: otherSoftware[1].Name, Source: otherSoftware[1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: otherSoftware[1].Version}, }}, + otherSoftware[2].Name + otherSoftware[2].Source: {Name: otherSoftware[2].Name, Source: otherSoftware[2].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ + {Version: otherSoftware[2].Version, Vulnerabilities: []string{vulns[4].CVE}}, + }}, "i1apps": { Name: "i1", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), + Status: expectStatus(fleet.SoftwareInstallPending), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI1UUID}}, }, "i2apps": { Name: "i2", Source: "apps", - Status: expectStatus(fleet.SoftwareInstallerPending), + Status: expectStatus(fleet.SoftwareInstallPending), SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI2UUID}}, }, } @@ -3831,68 +3965,94 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // test the pagination cases := []struct { + name string opts fleet.HostSoftwareTitleListOptions wantNames []string wantMeta *fleet.PaginationMetadata }{ { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: false}, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 9}, + name: "No available for install software, page 0", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 5}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name, byNSV[d].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 13}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: false}, - wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9}, + name: "No available for install software, page 1", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 5}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[e2].Name, i0.Name, i1.Name, i4.Name, i5.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 13}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 3}, IncludeAvailableForInstall: false}, - wantNames: []string{i1.Name, "vpp1", "vpp2"}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9}, + name: "No available for install software, page 2", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 5}, IncludeAvailableForInstall: false}, + wantNames: []string{i6.Name, "vpp1", "vpp2"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 13}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3}, IncludeAvailableForInstall: false}, + name: "No available for install software, page 3", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 5}, IncludeAvailableForInstall: false}, wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 13}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 4}, IncludeAvailableForInstall: true}, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 11}, + name: "Include Available for install software, page 0", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 5}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name, byNSV[d].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 15}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, IncludeAvailableForInstall: true}, - wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 11}, + name: "Include Available for install software, page 1", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 5}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[e2].Name, i0.Name, i1.Name, i2.Name, i4.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 15}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, IncludeAvailableForInstall: true}, - wantNames: []string{"vpp1", "vpp2", "vpp3"}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 11}, + name: "Include Available for install software, page 2", + opts: fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{Page: 2, PerPage: 5}, + IncludeAvailableForInstall: true, + }, + wantNames: []string{i5.Name, i6.Name, "vpp1", "vpp2", "vpp3"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 15}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, - wantNames: []string{byNSV[b].Name, i0.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2}, - }, - { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + name: "Include Available for install software, page 3", + opts: fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{Page: 3, PerPage: 5}, + IncludeAvailableForInstall: true, + }, wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 2}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 15}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 0, PerPage: 2}, AvailableForInstall: true}, - wantNames: []string{"i2", "vpp3"}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2}, + name: "Available for install and self-service only software, page 0", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{byNSV[b].Name, i0.Name, "vpp3"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 3}, }, { - opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 1}, AvailableForInstall: true}, - wantNames: []string{"vpp3"}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 2}, + name: "Available for install and self-service only software, page 1", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 3}, + }, + { + name: "Only available for install software, page 0", + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 0, PerPage: 4}, OnlyAvailableForInstall: true}, + wantNames: []string{byNSV[b].Name, "i0", "i1", "i2"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 10}, + }, + { + opts: fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, + OnlyAvailableForInstall: true, + }, + wantNames: []string{"vpp2", "vpp3"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 10}, }, } for _, c := range cases { - t.Run(fmt.Sprintf("%#v", c.opts), func(t *testing.T) { + t.Run(fmt.Sprintf("%s", c.name), func(t *testing.T) { // always include metadata c.opts.ListOptions.IncludeMetadata = true c.opts.ListOptions.OrderKey = "name" @@ -3915,8 +4075,12 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { ctx := context.Background() host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) nanoEnroll(t, ds, host, false) - opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", - TestSecondaryOrderKey: "source"}} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{ + PerPage: 10, IncludeMetadata: true, OrderKey: "name", + TestSecondaryOrderKey: "source", + }} + + test.CreateInsertGlobalVPPToken(t, ds) user, err := ds.NewUser(ctx, &fleet.User{ Password: []byte("p4ssw0rd.123"), @@ -4000,24 +4164,31 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { require.NoError(t, err) expected := map[string]fleet.HostSoftwareWithInstaller{ - byNSV[a1].Name + byNSV[a1].Source: {Name: byNSV[a1].Name, Source: byNSV[a1].Source, + byNSV[a1].Name + byNSV[a1].Source: { + Name: byNSV[a1].Name, Source: byNSV[a1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}}, - }}, - byNSV[b].Name + byNSV[b].Source: {Name: byNSV[b].Name, Source: byNSV[b].Source, + }, + }, + byNSV[b].Name + byNSV[b].Source: { + Name: byNSV[b].Name, Source: byNSV[b].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}}, - }}, + }, + }, // c1 and c2 are the same software title because they have the same name and source - byNSV[c1].Name + byNSV[c1].Source: {Name: byNSV[c1].Name, Source: byNSV[c1].Source, + byNSV[c1].Name + byNSV[c1].Source: { + Name: byNSV[c1].Name, Source: byNSV[c1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[c1].Version}, {Version: byNSV[c2].Version}, - }}, + }, + }, } compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, - expectOmitted ...string) { + expectOmitted ...string, + ) { require.Len(t, got, len(expected)-len(expectOmitted)) prev := "" for _, g := range got { @@ -4091,12 +4262,12 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { opts.VulnerableOnly = false // No software that is available for install - opts.AvailableForInstall = true + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) assert.Empty(t, sw) assert.Equal(t, &fleet.PaginationMetadata{}, meta) - opts.AvailableForInstall = false + opts.OnlyAvailableForInstall = false // Create a team tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "mobile team"}) @@ -4104,33 +4275,47 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and three for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) va4, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}, Name: "vpp4", - BundleIdentifier: "com.app.vpp4"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4", + BundleIdentifier: "com.app.vpp4", + }, nil) require.NoError(t, err) vpp2, vpp3, vpp4 := va2.AdamID, va3.AdamID, va4.AdamID @@ -4149,14 +4334,14 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { expected["vpp1ios_apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp1", Source: "ios_apps", - Status: expectStatus(fleet.SoftwareInstallerInstalled), - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}}, + Status: expectStatus(fleet.SoftwareInstalled), + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}}, } expected["vpp2ios_apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp2", Source: "ios_apps", - Status: expectStatus(fleet.SoftwareInstallerPending), - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}}, + Status: expectStatus(fleet.SoftwareInstallPending), + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}}, } opts.IncludeAvailableForInstall = false @@ -4170,15 +4355,17 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { Name: "vpp3", Source: "ios_apps", Status: nil, - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp3, SelfService: ptr.Bool(false)}, } expected["vpp4ios_apps"] = fleet.HostSoftwareWithInstaller{ Name: "vpp4", Source: "ios_apps", Status: nil, - AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp4}, + AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp4, SelfService: ptr.Bool(false)}, } expectedAvailableOnly := map[string]fleet.HostSoftwareWithInstaller{} + expectedAvailableOnly["vpp1ios_apps"] = expected["vpp1ios_apps"] + expectedAvailableOnly["vpp2ios_apps"] = expected["vpp2ios_apps"] expectedAvailableOnly["vpp3ios_apps"] = expected["vpp3ios_apps"] expectedAvailableOnly["vpp4ios_apps"] = expected["vpp4ios_apps"] opts.IncludeAvailableForInstall = true @@ -4189,13 +4376,12 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { compareResults(expected, sw, true) // Available for install only - opts.AvailableForInstall = true + opts.OnlyAvailableForInstall = true sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) assert.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expectedAvailableOnly))}, meta) compareResults(expectedAvailableOnly, sw, true) - opts.AvailableForInstall = false - + opts.OnlyAvailableForInstall = false } func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) { @@ -4211,6 +4397,14 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) { } scriptContentID, _ := res.LastInsertId() + uninstallScript := `echo 'bar'` + resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, + uninstallScript, uninstallScript) + if err != nil { + return err + } + uninstallScriptContentID, _ := resUninstall.LastInsertId() + res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', 'apps')`) if err != nil { return err @@ -4219,10 +4413,10 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) { res, err = q.ExecContext(ctx, ` INSERT INTO software_installers - (title_id, filename, version, install_script_content_id, storage_id) + (title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id) VALUES - (?, ?, ?, ?, unhex(?))`, - titleID, "installer.pkg", "v1.0.0", scriptContentID, hex.EncodeToString([]byte("test"))) + (?, ?, ?, ?, ?, ?, unhex(?))`, + titleID, "installer.pkg", "pkg", "v1.0.0", scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test"))) if err != nil { return err } @@ -4334,3 +4528,663 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) { require.Error(t, err) require.True(t, fleet.IsNotFound(err)) } + +func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "user1", "user1@example.com", false) + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, host, false) + opts := fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}, + IncludeAvailableForInstall: true, + } + + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) + require.NoError(t, err) + + err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{host.ID}) + require.NoError(t, err) + host.TeamID = &team1.ID + + // add a single "externally-installed" software for that host + software := []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // create a software installer for team 1 + installerTm1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + TeamID: &team1.ID, + UserID: user.ID, + }) + require.NoError(t, err) + + // install it on the host + hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerTm1, false) + require.NoError(t, err) + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host.ID, + InstallUUID: hostInstall1, + InstallScriptExitCode: ptr.Int(0), + }) + require.NoError(t, err) + + // add a VPP app for team 1 + vppTm1, err := ds.InsertVPPAppWithTeam(ctx, + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &team1.ID) + require.NoError(t, err) + + // fail to install it on the host + vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vppTm1.AdamID, user.ID) + createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusError) + + // add the successful installer to the reported installed software + software = []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "file1", Version: "1.0", Source: "apps"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // listing the host's software (including available for install) at this + // point lists "a", "file1" and "vpp1" (because of the install attempt) + sw, meta, err := ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.EqualValues(t, 3, meta.TotalResults) + require.Equal(t, sw[0].Name, "a") + require.Nil(t, sw[0].AppStoreApp) + require.Nil(t, sw[0].SoftwarePackage) + require.Equal(t, sw[1].Name, "file1") + require.Nil(t, sw[1].AppStoreApp) + require.NotNil(t, sw[1].SoftwarePackage) + require.Equal(t, sw[2].Name, "vpp1") + require.NotNil(t, sw[2].AppStoreApp) + require.Nil(t, sw[2].SoftwarePackage) + + // move host to team 2 + err = ds.AddHostsToTeam(ctx, &team2.ID, []uint{host.ID}) + require.NoError(t, err) + host.TeamID = &team2.ID + + // listing the host's software (including available for install) should now + // only list "a" and "file1" (because they are actually installed) and not + // link them to the installer/VPP app. With and without available software + // should result in the same rows (no available software in that new team). + for _, b := range []bool{true, false} { + opts.IncludeAvailableForInstall = b + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 2) + require.EqualValues(t, 2, meta.TotalResults) + require.Equal(t, sw[0].Name, "a") + require.Nil(t, sw[0].AppStoreApp) + require.Nil(t, sw[0].SoftwarePackage) + require.Equal(t, sw[1].Name, "file1") + require.Nil(t, sw[1].AppStoreApp) + require.Nil(t, sw[1].SoftwarePackage) + } +} + +func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "user1", "user1@example.com", false) + host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, host, false) + opts := fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}, + IncludeAvailableForInstall: true, + } + + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) + require.NoError(t, err) + + err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{host.ID}) + require.NoError(t, err) + host.TeamID = &team1.ID + + // add a single "externally-installed" software for that host + software := []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // create a software installer for team 1 + installerTm1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + TeamID: &team1.ID, + UserID: user.ID, + }) + require.NoError(t, err) + + // fail to install it on the host + hostInstall1, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerTm1, false) + require.NoError(t, err) + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host.ID, + InstallUUID: hostInstall1, + InstallScriptExitCode: ptr.Int(1), + }) + require.NoError(t, err) + + // add a VPP app for team 1 + vppTm1, err := ds.InsertVPPAppWithTeam(ctx, + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0", + }, &team1.ID) + require.NoError(t, err) + + // install it on the host + vpp1CmdUUID := createVPPAppInstallRequest(t, ds, host, vppTm1.AdamID, user.ID) + createVPPAppInstallResult(t, ds, host, vpp1CmdUUID, fleet.MDMAppleStatusAcknowledged) + + // add the successful VPP app to the reported installed software + software = []fleet.Software{ + {Name: "a", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "vpp1", Version: "1.0", Source: "apps", BundleIdentifier: "com.app.vpp1"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // listing the host's software (including available for install) at this + // point lists "a", "file1" and "vpp1" (because of the install attempt) + sw, meta, err := ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 3) + require.EqualValues(t, 3, meta.TotalResults) + require.Equal(t, sw[0].Name, "a") + require.Nil(t, sw[0].AppStoreApp) + require.Nil(t, sw[0].SoftwarePackage) + require.Equal(t, sw[1].Name, "file1") + require.Nil(t, sw[1].AppStoreApp) + require.NotNil(t, sw[1].SoftwarePackage) + require.Equal(t, sw[2].Name, "vpp1") + require.NotNil(t, sw[2].AppStoreApp) + require.Nil(t, sw[2].SoftwarePackage) + + // delete both installers + err = ds.DeleteSoftwareInstaller(ctx, installerTm1) + require.NoError(t, err) + err = ds.DeleteVPPAppFromTeam(ctx, &team1.ID, vppTm1.VPPAppID) + require.NoError(t, err) + + // listing the host's software (including available for install) should now + // only list "a" and "vpp1" (because they are actually installed) and not + // link them to the installer/VPP app. With and without available software + // should result in the same rows (no available software anymore). + for _, b := range []bool{true, false} { + opts.IncludeAvailableForInstall = b + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Len(t, sw, 2) + require.EqualValues(t, 2, meta.TotalResults) + require.Equal(t, sw[0].Name, "a") + require.Nil(t, sw[0].AppStoreApp) + require.Nil(t, sw[0].SoftwarePackage) + require.Equal(t, sw[1].Name, "vpp1") + require.Nil(t, sw[1].AppStoreApp) + require.Nil(t, sw[1].SoftwarePackage) + } +} + +func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) { + ctx := context.Background() + host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) + + software := []fleet.Software{ + {Name: "chrome", Version: "0.0.1", Source: "apps"}, + {Name: "chrome", Version: "0.0.3", Source: "apps"}, + {Name: "safari", Version: "0.0.3", Source: "apps"}, + {Name: "safari", Version: "0.0.1", Source: "apps"}, + {Name: "firefox", Version: "0.0.3", Source: "apps"}, + {Name: "edge", Version: "0.0.3", Source: "apps"}, + {Name: "brave", Version: "0.0.3", Source: "apps"}, + {Name: "opera", Version: "0.0.3", Source: "apps"}, + {Name: "internet explorer", Version: "0.0.3", Source: "apps"}, + {Name: "netscape", Version: "0.0.3", Source: "apps"}, + } + + sw, err := ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + var chrome001 uint + var safari001 uint + var firefox003 uint + var edge003 uint + var brave003 uint + var opera003 uint + var ie003 uint + for s := range sw.Inserted { + switch { + case sw.Inserted[s].Name == "chrome" && sw.Inserted[s].Version == "0.0.1": + chrome001 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "safari" && sw.Inserted[s].Version == "0.0.1": + safari001 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "firefox" && sw.Inserted[s].Version == "0.0.3": + firefox003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "edge" && sw.Inserted[s].Version == "0.0.3": + edge003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "brave" && sw.Inserted[s].Version == "0.0.3": + brave003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "opera" && sw.Inserted[s].Version == "0.0.3": + opera003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "internet explorer" && sw.Inserted[s].Version == "0.0.3": + ie003 = sw.Inserted[s].ID + } + } + + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: chrome001, + CVE: "CVE-2024-1234", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: safari001, + CVE: "CVE-2024-1235", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: firefox003, + CVE: "CVE-2024-1236", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: edge003, + CVE: "CVE-2024-1237", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: brave003, + CVE: "CVE-2024-1238", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: opera003, + CVE: "CVE-2024-1239", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: ie003, + CVE: "CVE-2024-1240", + }, fleet.NVDSource) + require.NoError(t, err) + + err = ds.InsertCVEMeta(ctx, []fleet.CVEMeta{ + { + // chrome + CVE: "CVE-2024-1234", + CVSSScore: ptr.Float64(7.5), + CISAKnownExploit: ptr.Bool(true), + }, + { + // safari + CVE: "CVE-2024-1235", + CVSSScore: ptr.Float64(7.5), + CISAKnownExploit: ptr.Bool(false), + }, + { + // firefox + CVE: "CVE-2024-1236", + CVSSScore: ptr.Float64(8.0), + CISAKnownExploit: ptr.Bool(true), + }, + { + // edge + CVE: "CVE-2024-1237", + CVSSScore: ptr.Float64(8.0), + CISAKnownExploit: ptr.Bool(false), + }, + { + // brave + CVE: "CVE-2024-1238", + CVSSScore: ptr.Float64(9.0), + CISAKnownExploit: ptr.Bool(true), + }, + // CVE-2024-1239 for opera has no CVE Meta + { + // internet explorer + CVE: "CVE-2024-1240", + CVSSScore: nil, + CISAKnownExploit: nil, + }, + }) + require.NoError(t, err) + + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + type swVersion struct { + Name string + Version string + } + + tc := []struct { + name string + opts fleet.SoftwareListOptions + expected []swVersion + err error + }{ + { + name: "vulnerable only", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + VulnerableOnly: true, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "edge", + Version: "0.0.3", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + { + Name: "internet explorer", + Version: "0.0.3", + }, + { + Name: "opera", + Version: "0.0.3", + }, + { + Name: "safari", + Version: "0.0.1", + }, + }, + }, + { + name: "known exploit true", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + KnownExploit: true, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "minimum cvss 8.0", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 8.0, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "edge", + Version: "0.0.3", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "minimum cvss 7.9", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 7.9, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "edge", + Version: "0.0.3", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "minimum cvss 8.0 and known exploit", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 8.0, + KnownExploit: true, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "minimum cvss 7.5 and known exploit", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 7.5, + KnownExploit: true, + }, + expected: []swVersion{ + { + Name: "brave", + Version: "0.0.3", + }, + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "maximum cvss 7.5", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MaximumCVSS: 7.5, + }, + expected: []swVersion{ + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "safari", + Version: "0.0.1", + }, + }, + }, + { + name: "maximum cvss 7.6", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MaximumCVSS: 7.6, + }, + expected: []swVersion{ + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "safari", + Version: "0.0.1", + }, + }, + }, + { + name: "maximum cvss 7.5 and known exploit", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MaximumCVSS: 7.5, + KnownExploit: true, + }, + expected: []swVersion{ + { + Name: "chrome", + Version: "0.0.1", + }, + }, + }, + { + name: "minimum cvss 7.5 and maximum cvss 8.0", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 7.5, + MaximumCVSS: 8.0, + }, + expected: []swVersion{ + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "edge", + Version: "0.0.3", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + { + Name: "safari", + Version: "0.0.1", + }, + }, + }, + { + name: "minimum cvss 7.5 and maximum cvss 8.0 and known exploit", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + IncludeCVEScores: true, + VulnerableOnly: true, + MinimumCVSS: 7.5, + MaximumCVSS: 8.0, + KnownExploit: true, + }, + expected: []swVersion{ + { + Name: "chrome", + Version: "0.0.1", + }, + { + Name: "firefox", + Version: "0.0.3", + }, + }, + }, + { + name: "err if vulnerableOnly is not set with MinimumCVSS", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{}, + MinimumCVSS: 7.5, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + { + name: "err if vulnerableOnly is not set with MaximumCVSS", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{}, + MaximumCVSS: 7.5, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + { + name: "err if vulnerableOnly is not set with KnownExploit", + opts: fleet.SoftwareListOptions{ + ListOptions: fleet.ListOptions{}, + KnownExploit: true, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + sw, _, err := ds.ListSoftware(ctx, tt.opts) + if tt.err != nil { + require.Error(t, err) + require.Equal(t, tt.err, err) + return + } + require.Len(t, sw, len(tt.expected)) + for i, s := range sw { + require.Equal(t, tt.expected[i].Name, s.Name) + require.Equal(t, tt.expected[i].Version, s.Version) + } + count, err := ds.CountSoftware(ctx, tt.opts) + require.NoError(t, err) + require.Equal(t, len(tt.expected), count) + }) + } +} diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 2c214616dc..b4e2bb6e60 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -13,18 +13,24 @@ import ( ) func (ds *Datastore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) { - var teamFilter string // used to filter software titles host counts by team + var ( + teamFilter string // used to filter software titles host counts by team + softwareInstallerGlobalOrTeamIDFilter string + vppAppsTeamsGlobalOrTeamIDFilter string + ) + if teamID != nil { teamFilter = fmt.Sprintf("sthc.team_id = %d AND sthc.global_stats = 0", *teamID) + softwareInstallerGlobalOrTeamIDFilter = fmt.Sprintf("si.global_or_team_id = %d", *teamID) + vppAppsTeamsGlobalOrTeamIDFilter = fmt.Sprintf("vat.global_or_team_id = %d", *teamID) } else { teamFilter = ds.whereFilterGlobalOrTeamIDByTeams(tmFilter, "sthc") + softwareInstallerGlobalOrTeamIDFilter = "TRUE" + vppAppsTeamsGlobalOrTeamIDFilter = "TRUE" } - var tmID uint // used to filter software installers by team - if teamID != nil { - tmID = *teamID - } - + // Select software title but filter out if the software has zero host counts + // and it's not an installer or VPP app. selectSoftwareTitleStmt := fmt.Sprintf(` SELECT st.id, @@ -32,27 +38,27 @@ SELECT st.source, st.browser, st.bundle_identifier, - COALESCE(SUM(sthc.hosts_count), 0) as hosts_count, - MAX(sthc.updated_at) as counts_updated_at, + COALESCE(SUM(sthc.hosts_count), 0) AS hosts_count, + MAX(sthc.updated_at) AS counts_updated_at, COUNT(si.id) as software_installers_count, - COUNT(vat.adam_id) as vpp_apps_count + COUNT(vat.adam_id) AS vpp_apps_count FROM software_titles st -LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id -LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? +LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.hosts_count > 0 AND (%s) +LEFT JOIN software_installers si ON si.title_id = st.id AND %s LEFT JOIN vpp_apps vap ON vap.title_id = st.id -LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id AND vat.platform = vap.platform +LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s WHERE st.id = ? AND - ((sthc.hosts_count > 0 AND %s) OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL) + (sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL) GROUP BY st.id, st.name, st.source, st.browser, st.bundle_identifier - `, teamFilter, + `, teamFilter, softwareInstallerGlobalOrTeamIDFilter, vppAppsTeamsGlobalOrTeamIDFilter, ) var title fleet.SoftwareTitle - if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, tmID, tmID, id); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, id); err != nil { if err == sql.ErrNoRows { return nil, notFound("SoftwareTitle").WithID(id) } @@ -90,8 +96,8 @@ func (ds *Datastore) ListSoftwareTitles( opt.ListOptions.OrderDirection = fleet.OrderDescending } - if opt.AvailableForInstall && opt.VulnerableOnly { - return nil, 0, nil, fleet.NewInvalidArgumentError("query", "available_for_install and vulnerable can't be provided together") + if (opt.MinimumCVSS > 0 || opt.MaximumCVSS > 0 || opt.KnownExploit) && !opt.VulnerableOnly { + return nil, 0, nil, fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true") } dbReader := ds.reader(ctx) @@ -105,6 +111,7 @@ func (ds *Datastore) ListSoftwareTitles( PackageSelfService *bool `db:"package_self_service"` PackageName *string `db:"package_name"` PackageVersion *string `db:"package_version"` + PackageURL *string `db:"package_url"` VPPAppSelfService *bool `db:"vpp_app_self_service"` VPPAppAdamID *string `db:"vpp_app_adam_id"` VPPAppVersion *string `db:"vpp_app_version"` @@ -146,6 +153,7 @@ func (ds *Datastore) ListSoftwareTitles( Name: *title.PackageName, Version: version, SelfService: title.PackageSelfService, + PackageURL: title.PackageURL, } } @@ -256,32 +264,53 @@ SELECT si.self_service as package_self_service, si.filename as package_name, si.version as package_version, - -- in a future iteration, will be supported for VPP apps - 0 as vpp_app_self_service, + si.url AS package_url, + vat.self_service as vpp_app_self_service, vat.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, vap.icon_url as vpp_app_icon_url FROM software_titles st -LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? -LEFT JOIN vpp_apps vap ON vap.title_id = st.id -LEFT JOIN vpp_apps_teams vat ON vat.global_or_team_id = ? AND vat.adam_id = vap.adam_id AND vat.platform = vap.platform -LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ? +LEFT JOIN software_installers si ON si.title_id = st.id AND %s +LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND %s +LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s +LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND (%s) -- placeholder for JOIN on software/software_cve %s -- placeholder for optional extra WHERE filter WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` +GROUP BY st.id, package_self_service, package_name, package_version, package_url, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` cveJoinType := "LEFT" if opt.VulnerableOnly { cveJoinType = "INNER" } - args := []any{0, 0, 0} - if opt.TeamID != nil { - args[0], args[1], args[2] = *opt.TeamID, *opt.TeamID, *opt.TeamID + countsJoin := "TRUE" + softwareInstallersJoinCond := "TRUE" + vppAppsJoinCond := "TRUE" + vppAppsTeamsJoinCond := "TRUE" + includeVPPAppsAndSoftwareInstallers := "TRUE" + switch { + case opt.TeamID == nil: + countsJoin = "sthc.team_id = 0 AND sthc.global_stats = 1" + // When opt.TeamID is nil (aka "All teams") we do not include VPP-apps/installers + // that are not installed on any host. + includeVPPAppsAndSoftwareInstallers = "FALSE" + case *opt.TeamID == 0: + countsJoin = "sthc.team_id = 0 AND sthc.global_stats = 0" + softwareInstallersJoinCond = fmt.Sprintf("si.global_or_team_id = %d", *opt.TeamID) + vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) + case *opt.TeamID > 0: + countsJoin = fmt.Sprintf("sthc.team_id = %d AND sthc.global_stats = 0", *opt.TeamID) + softwareInstallersJoinCond = fmt.Sprintf("si.global_or_team_id = %d", *opt.TeamID) + vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) + } + + if opt.PackagesOnly { + vppAppsJoinCond = "FALSE" + vppAppsTeamsJoinCond = "FALSE" } additionalWhere := "TRUE" @@ -299,6 +328,31 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel `, cveJoinType) } + var args []any + if opt.VulnerableOnly && (opt.KnownExploit || opt.MinimumCVSS > 0 || opt.MaximumCVSS > 0) { + softwareJoin += ` + INNER JOIN cve_meta cm ON scve.cve = cm.cve + ` + if opt.KnownExploit { + softwareJoin += ` + AND cm.cisa_known_exploit = 1 + ` + } + if opt.MinimumCVSS > 0 { + softwareJoin += ` + AND cm.cvss_score >= ? + ` + args = append(args, opt.MinimumCVSS) + } + + if opt.MaximumCVSS > 0 { + softwareJoin += ` + AND cm.cvss_score <= ? + ` + args = append(args, opt.MaximumCVSS) + } + } + if match != "" { additionalWhere = " (st.name LIKE ? OR scve.cve LIKE ?)" match = likePattern(match) @@ -306,22 +360,19 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel } // default to "a software installer or VPP app exists", and see next condition. - defaultFilter := ` - (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) - ` + defaultFilter := fmt.Sprintf(` + ((si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s) + `, includeVPPAppsAndSoftwareInstallers) - // add software installed for hosts if any of this is true: - // - // - we're not filtering for "available for install" only - // - we're filtering by vulnerable only - if !opt.AvailableForInstall || opt.VulnerableOnly { + // add software installed for hosts if we're not filtering for "available for install" only + if !opt.AvailableForInstall { defaultFilter = ` ( ` + defaultFilter + ` OR sthc.hosts_count > 0 ) ` } if opt.SelfServiceOnly { - defaultFilter += ` AND si.self_service = 1 ` + defaultFilter += ` AND ( si.self_service = 1 OR vat.self_service = 1 ) ` } - stmt = fmt.Sprintf(stmt, softwareJoin, additionalWhere, defaultFilter) + stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) return stmt, args } diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 4bb20acfd8..c0cfa2020f 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -28,7 +28,9 @@ func TestSoftwareTitles(t *testing.T) { {"TeamFilterSoftwareTitles", testTeamFilterSoftwareTitles}, {"ListSoftwareTitlesInstallersOnly", testListSoftwareTitlesInstallersOnly}, {"ListSoftwareTitlesAvailableForInstallFilter", testListSoftwareTitlesAvailableForInstallFilter}, + {"ListSoftwareTitlesAllTeams", testListSoftwareTitlesAllTeams}, {"UploadedSoftwareExists", testUploadedSoftwareExists}, + {"ListSoftwareTitlesVulnerabilityFilters", testListSoftwareTitlesVulnerabilityFilters}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -259,12 +261,18 @@ func testSoftwareSyncHostsSoftwareTitles(t *testing.T, ds *Datastore) { } func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { + // + // All tests below are in hosts in "No team". + // + ctx := context.Background() host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + software1 := []fleet.Software{ {Name: "foo", Version: "0.0.1", Source: "chrome_extensions", Browser: "chrome"}, {Name: "foo", Version: "0.0.3", Source: "chrome_extensions", Browser: "chrome"}, @@ -297,6 +305,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -311,13 +320,19 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, ds) + // create a VPP app not installed anywhere - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.IPadOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.IPadOSPlatform}}, + }, nil) require.NoError(t, err) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) @@ -325,10 +340,13 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) // primary sort is "hosts_count DESC", followed by "name ASC, source ASC, browser ASC" - titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "hosts_count", - OrderDirection: fleet.OrderDescending, - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "hosts_count", + OrderDirection: fleet.OrderDescending, + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 10) i := 0 @@ -385,10 +403,13 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.NotNil(t, titles[i].AppStoreApp) // primary sort is "hosts_count ASC", followed by "name ASC, source ASC, browser ASC" - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "hosts_count", - OrderDirection: fleet.OrderAscending, - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "hosts_count", + OrderDirection: fleet.OrderAscending, + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 10) i = 0 @@ -425,10 +446,13 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "deb_packages", titles[i].Source) // primary sort is "name ASC", followed by "host_count DESC, source ASC, browser ASC" - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderAscending, - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 10) i = 0 @@ -465,10 +489,13 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { assert.Equal(t, "ipados_apps", titles[i].Source) // primary sort is "name DESC", followed by "host_count DESC, source ASC, browser ASC" - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderDescending, - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 10) i = 0 @@ -505,11 +532,14 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "apps", titles[i].Source) // using a match query - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderDescending, - MatchQuery: "ba", - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + MatchQuery: "ba", + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 4) require.Equal(t, "baz", titles[0].Name) @@ -524,11 +554,14 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "apps", titles[3].Source) // using another (installer-only) match query - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderDescending, - MatchQuery: "insta", - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + MatchQuery: "insta", + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 2) require.Equal(t, "installer2", titles[0].Name) @@ -537,10 +570,14 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "apps", titles[1].Source) // filter on self-service only - titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderDescending, - }, SelfServiceOnly: true}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + }, + TeamID: ptr.Uint(0), + SelfServiceOnly: true, + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.Len(t, titles, 1) require.Equal(t, "installer1", titles[0].Name) @@ -564,6 +601,10 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{host1.ID})) host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) @@ -597,6 +638,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { Filename: "installer1.pkg", BundleIdentifier: "foo.bar", TeamID: &team1.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -612,35 +654,43 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer2.pkg", TeamID: &team2.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) + // create a VPP app for team2 - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IOSPlatform}}, &team2.ID) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.app.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IOSPlatform}}, + }, &team2.ID) require.NoError(t, err) - // create a VPP app for No Team - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, ptr.Uint(0)) + // create a VPP app for "No team", allowing self-service + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp3", BundleIdentifier: "com.app.vpp3", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}, SelfService: true}, + }, ptr.Uint(0)) require.NoError(t, err) require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) - // Testing the global user (for no team) + // Testing the global user (for "All teams") + // Should not return VPP apps or software installers (because they are not installed yet). globalTeamFilter := fleet.TeamFilter{User: userGlobalAdmin, IncludeObserver: true} titles, count, _, err := ds.ListSoftwareTitles( - context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}}, globalTeamFilter, + context.Background(), fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + TeamID: nil, + }, globalTeamFilter, ) sortTitlesByName(titles) - // software installers are associated with a team, so they don't show up in - // this request for no team, but other titles do because software titles are - // not associated with a team. require.NoError(t, err) - require.Len(t, titles, 3) - require.Equal(t, 3, count) + require.Len(t, titles, 2) + require.Equal(t, 2, count) + require.Equal(t, "bar", titles[0].Name) require.Equal(t, "deb_packages", titles[0].Source) require.Equal(t, "foo", titles[1].Name) @@ -653,19 +703,39 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { assert.Equal(t, uint(2), titles[1].HostsCount) require.Nil(t, titles[1].SoftwarePackage) require.Nil(t, titles[1].AppStoreApp) - require.Equal(t, uint(0), titles[2].VersionsCount) - require.Nil(t, titles[2].SoftwarePackage) - require.Equal(t, "vpp3", titles[2].Name) + barTitle := titles[0] + fooTitle := titles[1] - title, err := ds.SoftwareTitleByID(context.Background(), titles[0].ID, nil, globalTeamFilter) + // Testing the global user (for "No team") + // should only return vpp3 because it's the only app in the "No team". + titles, count, _, err = ds.ListSoftwareTitles( + context.Background(), fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + TeamID: ptr.Uint(0), + }, globalTeamFilter, + ) + sortTitlesByName(titles) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, 1, count) + require.Equal(t, uint(0), titles[0].VersionsCount) + require.Nil(t, titles[0].SoftwarePackage) + require.Equal(t, "vpp3", titles[0].Name) + require.NotNil(t, titles[0].AppStoreApp) + require.NotNil(t, titles[0].AppStoreApp.SelfService) + require.True(t, *titles[0].AppStoreApp.SelfService) + + // Get title of bar software. + title, err := ds.SoftwareTitleByID(context.Background(), barTitle.ID, nil, globalTeamFilter) require.NoError(t, err) require.Zero(t, title.SoftwareInstallersCount) require.Zero(t, title.VPPAppsCount) + // ListSoftwareTitles does not populate version host counts, so we do that manually - titles[0].Versions[0].HostsCount = ptr.Uint(1) + barTitle.Versions[0].HostsCount = ptr.Uint(1) assert.Equal( t, - titles[0], + barTitle, fleet.SoftwareTitleListResult{ ID: title.ID, Name: title.Name, @@ -679,11 +749,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { ) // Testing with team filter -- this team does not contain this software title - _, err = ds.SoftwareTitleByID(context.Background(), titles[0].ID, &team1.ID, globalTeamFilter) + _, err = ds.SoftwareTitleByID(context.Background(), barTitle.ID, &team1.ID, globalTeamFilter) assert.ErrorIs(t, err, sql.ErrNoRows) // Testing with team filter -- this team does contain this software title - title, err = ds.SoftwareTitleByID(context.Background(), titles[1].ID, &team1.ID, globalTeamFilter) + title, err = ds.SoftwareTitleByID(context.Background(), fooTitle.ID, &team1.ID, globalTeamFilter) require.NoError(t, err) require.Zero(t, title.SoftwareInstallersCount) require.Zero(t, title.VPPAppsCount) @@ -781,6 +851,15 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.Len(t, titles, 0) + + // Testing the no-team filter with self-service only + titles, _, _, err = ds.ListSoftwareTitles(context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: ptr.Uint(0)}, fleet.TeamFilter{ + User: userGlobalAdmin, + IncludeObserver: true, + }) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "vpp3", titles[0].Name) } func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { @@ -790,12 +869,17 @@ func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + test.CreateInsertGlobalVPPToken(t, ds) + // create a couple software installers not installed on any host installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -804,18 +888,24 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) // create a VPP app not installed on a host - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app,vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app,vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) - titles, counts, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderAscending, - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, counts, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.EqualValues(t, 3, counts) require.Len(t, titles, 3) @@ -834,11 +924,14 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) // match installer1 name - titles, counts, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderAscending, - MatchQuery: "installer1", - }}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, counts, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + MatchQuery: "installer1", + }, + TeamID: ptr.Uint(0), + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.EqualValues(t, 1, counts) require.Len(t, titles, 1) @@ -847,11 +940,15 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { require.True(t, titles[0].CountsUpdatedAt.IsZero()) // vulnerable only returns nothing - titles, counts, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ - OrderKey: "name", - OrderDirection: fleet.OrderAscending, - MatchQuery: "installer1", - }, VulnerableOnly: true}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + titles, counts, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + MatchQuery: "installer1", + }, + TeamID: ptr.Uint(0), + VulnerableOnly: true, + }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) require.EqualValues(t, 0, counts) require.Len(t, titles, 0) @@ -865,6 +962,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { OrderDirection: fleet.OrderAscending, }, AvailableForInstall: true, + TeamID: ptr.Uint(0), }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) @@ -876,13 +974,17 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) - // create a 2 software installers + test.CreateInsertGlobalVPPToken(t, ds) + + // create 2 software installers installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -891,22 +993,31 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) // create a 4 VPP apps - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.example.vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.example.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.example.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IPadOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.example.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IPadOSPlatform}}, + }, nil) require.NoError(t, err) - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.example.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.example.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.example.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IOSPlatform}}, nil) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.example.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.IOSPlatform}}, + }, nil) require.NoError(t, err) host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) @@ -914,6 +1025,8 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, {Name: "foo", Version: "0.0.3", Source: "chrome_extensions"}, {Name: "bar", Version: "0.0.3", Source: "deb_packages"}, + {Name: "vpp1", Version: "0.0.1", Source: "apps"}, + {Name: "installer1", Version: "0.0.1", Source: "apps"}, } _, err = ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -929,6 +1042,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore OrderKey: "name", OrderDirection: fleet.OrderAscending, }, + TeamID: ptr.Uint(0), }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) @@ -954,6 +1068,63 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore {name: "vpp2", source: "apps"}, }, names) + var vppVersionID uint + var installer1ID uint + var fooID uint + for _, title := range titles { + switch title.Name { + case "vpp1": + vppVersionID = title.Versions[0].ID + case "installer1": + installer1ID = title.Versions[0].ID + case "foo": + fooID = title.Versions[0].ID + } + } + + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: vppVersionID, + CVE: "CVE-2021-1234", + }, fleet.NVDSource) + require.NoError(t, err) + + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: installer1ID, + CVE: "CVE-2021-1234", + }, fleet.NVDSource) + require.NoError(t, err) + + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: fooID, + CVE: "CVE-2021-1234", + }, fleet.NVDSource) + require.NoError(t, err) + + titles, counts, _, err = ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: ptr.Uint(0), + AvailableForInstall: true, + VulnerableOnly: true, + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + require.EqualValues(t, 2, counts) + require.Len(t, titles, 2) + names = make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "installer1", source: "apps"}, + {name: "vpp1", source: "apps"}, + }, names) + // with filter returns only available for install titles, counts, _, err = ds.ListSoftwareTitles( ctx, @@ -963,6 +1134,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore OrderDirection: fleet.OrderAscending, }, AvailableForInstall: true, + TeamID: ptr.Uint(0), }, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, ) @@ -984,11 +1156,202 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore }, names) } +func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { + ctx := context.Background() + + test.CreateInsertGlobalVPPToken(t, ds) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // Create a macOS software foobar installer on "No team". + macOSInstallerNoTeam, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "foobar", + BundleIdentifier: "com.foo.bar", + Source: "apps", + InstallScript: "echo", + Filename: "foobar.pkg", + TeamID: nil, + UserID: user1.ID, + }) + require.NoError(t, err) + + // Create an iOS Canva installer on "team1". + require.NotZero(t, macOSInstallerNoTeam) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "Canva", BundleIdentifier: "com.example.canva", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_canva", Platform: fleet.IOSPlatform}}, + }, &team1.ID) + require.NoError(t, err) + + // Create a macOS Canva installer on "team1". + require.NotZero(t, macOSInstallerNoTeam) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "Canva", BundleIdentifier: "com.example.canva", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_canva", Platform: fleet.MacOSPlatform}}, + }, &team1.ID) + require.NoError(t, err) + + // Create an iPadOS Canva installer on "team2". + require.NotZero(t, macOSInstallerNoTeam) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "Canva", BundleIdentifier: "com.example.canva", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_canva", Platform: fleet.IPadOSPlatform}}, + }, &team2.ID) + require.NoError(t, err) + + // Add a macOS host on "No team" with some software. + host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) + software := []fleet.Software{ + {Name: "foo", Version: "0.0.1", Source: "chrome_extensions"}, + {Name: "foo", Version: "0.0.3", Source: "chrome_extensions"}, + {Name: "bar", Version: "0.0.3", Source: "deb_packages"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + // Simulate vulnerabilities cron + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + // List software titles for "All teams", should only return the host software titles + // and no installers/VPP-apps because none is installed yet. + titles, counts, _, err := ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: nil, + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + assert.EqualValues(t, 2, counts) + assert.Len(t, titles, 2) + type nameSource struct { + name string + source string + } + names := make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "bar", source: "deb_packages"}, + {name: "foo", source: "chrome_extensions"}, + }, names) + + // List software for "No team". Should list the host's software + the macOS installer. + titles, counts, _, err = ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: ptr.Uint(0), + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + assert.EqualValues(t, 3, counts) + assert.Len(t, titles, 3) + names = make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "bar", source: "deb_packages"}, + {name: "foo", source: "chrome_extensions"}, + {name: "foobar", source: "apps"}, + }, names) + + // List software for "team1". Should list Canva for iOS and macOS. + titles, counts, _, err = ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: &team1.ID, + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + assert.EqualValues(t, 2, counts) + assert.Len(t, titles, 2) + names = make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "Canva", source: "ios_apps"}, + {name: "Canva", source: "apps"}, + }, names) + + // List software for "team2". Should list Canva for iPadOS. + titles, counts, _, err = ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + TeamID: &team2.ID, + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + assert.EqualValues(t, 1, counts) + assert.Len(t, titles, 1) + names = make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "Canva", source: "ipados_apps"}, + }, names) + + // List software available for install on "No team". Should list "foobar" package only. + titles, counts, _, err = ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + }, + AvailableForInstall: true, + TeamID: ptr.Uint(0), + }, + fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}, + ) + require.NoError(t, err) + require.EqualValues(t, 1, counts) + require.Len(t, titles, 1) + + names = make([]nameSource, 0, len(titles)) + for _, title := range titles { + names = append(names, nameSource{name: title.Name, source: title.Source}) + } + assert.ElementsMatch(t, []nameSource{ + {name: "foobar", source: "apps"}, + }, names) +} + func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { ctx := context.Background() tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team Foo"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", @@ -996,6 +1359,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer1.pkg", BundleIdentifier: "com.foo.installer1", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -1006,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { Filename: "installer2.pkg", TeamID: &tm.ID, BundleIdentifier: "com.foo.installer2", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -1022,3 +1387,289 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, exists) } + +func testListSoftwareTitlesVulnerabilityFilters(t *testing.T, ds *Datastore) { + ctx := context.Background() + host := test.NewHost(t, ds, "host", "", "hostkey", "hostuuid", time.Now()) + + software := []fleet.Software{ + {Name: "chrome", Version: "0.0.1", Source: "apps"}, + {Name: "chrome", Version: "0.0.3", Source: "apps"}, + {Name: "safari", Version: "0.0.3", Source: "apps"}, + {Name: "safari", Version: "0.0.1", Source: "apps"}, + {Name: "firefox", Version: "0.0.3", Source: "apps"}, + {Name: "edge", Version: "0.0.3", Source: "apps"}, + {Name: "brave", Version: "0.0.3", Source: "apps"}, + {Name: "opera", Version: "0.0.3", Source: "apps"}, + {Name: "internet explorer", Version: "0.0.3", Source: "apps"}, + {Name: "netscape", Version: "0.0.3", Source: "apps"}, + } + + sw, err := ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + + var chrome001 uint + var safari001 uint + var firefox003 uint + var edge003 uint + var brave003 uint + var opera003 uint + var ie003 uint + for s := range sw.Inserted { + switch { + case sw.Inserted[s].Name == "chrome" && sw.Inserted[s].Version == "0.0.1": + chrome001 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "safari" && sw.Inserted[s].Version == "0.0.1": + safari001 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "firefox" && sw.Inserted[s].Version == "0.0.3": + firefox003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "edge" && sw.Inserted[s].Version == "0.0.3": + edge003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "brave" && sw.Inserted[s].Version == "0.0.3": + brave003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "opera" && sw.Inserted[s].Version == "0.0.3": + opera003 = sw.Inserted[s].ID + case sw.Inserted[s].Name == "internet explorer" && sw.Inserted[s].Version == "0.0.3": + ie003 = sw.Inserted[s].ID + } + } + + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: chrome001, + CVE: "CVE-2024-1234", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: safari001, + CVE: "CVE-2024-1235", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: firefox003, + CVE: "CVE-2024-1236", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: edge003, + CVE: "CVE-2024-1237", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: brave003, + CVE: "CVE-2024-1238", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: opera003, + CVE: "CVE-2024-1239", + }, fleet.NVDSource) + require.NoError(t, err) + _, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + SoftwareID: ie003, + CVE: "CVE-2024-1240", + }, fleet.NVDSource) + require.NoError(t, err) + + err = ds.InsertCVEMeta(ctx, []fleet.CVEMeta{ + { + // chrome + CVE: "CVE-2024-1234", + CVSSScore: ptr.Float64(7.5), + CISAKnownExploit: ptr.Bool(true), + }, + { + // safari + CVE: "CVE-2024-1235", + CVSSScore: ptr.Float64(7.5), + CISAKnownExploit: ptr.Bool(false), + }, + { + // firefox + CVE: "CVE-2024-1236", + CVSSScore: ptr.Float64(8.0), + CISAKnownExploit: ptr.Bool(true), + }, + { + // edge + CVE: "CVE-2024-1237", + CVSSScore: ptr.Float64(8.0), + CISAKnownExploit: ptr.Bool(false), + }, + { + // brave + CVE: "CVE-2024-1238", + CVSSScore: ptr.Float64(9.0), + CISAKnownExploit: ptr.Bool(true), + }, + // CVE-2024-1239 for opera has no CVE Meta + { + // internet explorer + CVE: "CVE-2024-1240", + CVSSScore: nil, + CISAKnownExploit: nil, + }, + }) + require.NoError(t, err) + + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + globalUser := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} + + tc := []struct { + name string + opts fleet.SoftwareTitleListOptions + expectedTitles []string + err error + }{ + { + name: "vulnerable only", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + }, + expectedTitles: []string{"chrome", "safari", "firefox", "edge", "brave", "opera", "internet explorer"}, + }, + { + name: "known exploit true", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + KnownExploit: true, + }, + expectedTitles: []string{"chrome", "firefox", "brave"}, + }, + { + name: "minimum cvss 8.0", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 8.0, + }, + expectedTitles: []string{"edge", "firefox", "brave"}, + }, + { + name: "minimum cvss 7.9", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 7.9, + }, + expectedTitles: []string{"edge", "firefox", "brave"}, + }, + { + name: "minimum cvss 8.0 and known exploit", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 8.0, + KnownExploit: true, + }, + expectedTitles: []string{"firefox", "brave"}, + }, + { + name: "minimum cvss 7.5 and known exploit", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 7.5, + KnownExploit: true, + }, + expectedTitles: []string{"chrome", "firefox", "brave"}, + }, + { + name: "maximum cvss 7.5", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MaximumCVSS: 7.5, + }, + expectedTitles: []string{"chrome", "safari"}, + }, + { + name: "maximum cvss 7.6", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MaximumCVSS: 7.6, + }, + expectedTitles: []string{"chrome", "safari"}, + }, + { + name: "maximum cvss 7.5 and known exploit", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MaximumCVSS: 7.5, + KnownExploit: true, + }, + expectedTitles: []string{"chrome"}, + }, + { + name: "minimum cvss 7.5 and maximum cvss 8.0", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 7.5, + MaximumCVSS: 8.0, + }, + expectedTitles: []string{"chrome", "safari", "firefox", "edge"}, + }, + { + name: "minimum cvss 7.5 and maximum cvss 8.0 and known exploit", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + VulnerableOnly: true, + MinimumCVSS: 7.5, + MaximumCVSS: 8.0, + KnownExploit: true, + }, + expectedTitles: []string{"chrome", "firefox"}, + }, + { + name: "err if vulnerableOnly is not set with MinimumCVSS", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + MinimumCVSS: 7.5, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + { + name: "err if vulnerableOnly is not set with MaximumCVSS", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + MaximumCVSS: 7.5, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + { + name: "err if vulnerableOnly is not set with KnownExploit", + opts: fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{}, + KnownExploit: true, + }, + err: fleet.NewInvalidArgumentError("query", "min_cvss_score, max_cvss_score, and exploit can only be provided with vulnerable=true"), + }, + } + + assertTitles := func(t *testing.T, titles []fleet.SoftwareTitleListResult, expectedTitles []string) { + t.Helper() + require.Len(t, titles, len(expectedTitles)) + for _, title := range titles { + require.Contains(t, expectedTitles, title.Name) + } + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + titles, _, _, err := ds.ListSoftwareTitles(ctx, tt.opts, fleet.TeamFilter{User: globalUser}) + if tt.err != nil { + require.Error(t, err) + require.Equal(t, tt.err, err) + return + } + assertTitles(t, titles, tt.expectedTitles) + }) + } +} diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 55e9b9e4ee..31b65429fe 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -10,6 +10,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/version" "github.com/go-kit/log/level" "github.com/jmoiron/sqlx" @@ -98,6 +99,10 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "amount hosts by osquery version") } + numHostsFleetDesktopEnabled, err := numHostsFleetDesktopEnabledDB(ctx, ds.reader(ctx)) + if err != nil { + return ctxerr.Wrap(ctx, err, "number of hosts with Fleet desktop installed") + } stats.NumHostsEnrolled = amountEnrolledHosts stats.NumUsers = amountUsers @@ -130,6 +135,23 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if lic != nil && lic.IsPremium() { stats.Organization = lic.Organization } + stats.AIFeaturesDisabled = appConfig.ServerSettings.AIFeaturesDisabled + stats.MaintenanceWindowsConfigured = len(appConfig.Integrations.GoogleCalendar) > 0 && appConfig.Integrations.GoogleCalendar[0].Domain != "" && len(appConfig.Integrations.GoogleCalendar[0].ApiKey) > 0 + + stats.MaintenanceWindowsEnabled = false + teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: &fleet.User{ + GlobalRole: ptr.String(fleet.RoleAdmin), + }}, fleet.ListOptions{}) + if err != nil { + return ctxerr.Wrap(ctx, err, "list teams") + } + for _, team := range teams { + if team.Config.Integrations.GoogleCalendar != nil && team.Config.Integrations.GoogleCalendar.Enable { + stats.MaintenanceWindowsEnabled = true + break + } + } + stats.NumHostsFleetDesktopEnabled = numHostsFleetDesktopEnabled return nil } diff --git a/server/datastore/mysql/statistics_test.go b/server/datastore/mysql/statistics_test.go index 0f8e5b426a..c152e03429 100644 --- a/server/datastore/mysql/statistics_test.go +++ b/server/datastore/mysql/statistics_test.go @@ -87,6 +87,10 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, false, stats.HostExpiryEnabled) assert.Equal(t, false, stats.MDMWindowsEnabled) assert.Equal(t, false, stats.LiveQueryDisabled) + assert.Equal(t, false, stats.AIFeaturesDisabled) + assert.Equal(t, false, stats.MaintenanceWindowsEnabled) + assert.Equal(t, false, stats.MaintenanceWindowsConfigured) + assert.Equal(t, 0, stats.NumHostsFleetDesktopEnabled) firstIdentifier := stats.AnonymousIdentifier @@ -227,6 +231,10 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors)) assert.Equal(t, []fleet.HostsCountByOsqueryVersion{{OsqueryVersion: "4.9.0", NumHosts: 1}}, stats.HostsEnrolledByOsqueryVersion) assert.Equal(t, []fleet.HostsCountByOrbitVersion{{OrbitVersion: "1.1.0", NumHosts: 1}}, stats.HostsEnrolledByOrbitVersion) + assert.Equal(t, false, stats.AIFeaturesDisabled) + assert.Equal(t, false, stats.MaintenanceWindowsEnabled) + assert.Equal(t, false, stats.MaintenanceWindowsConfigured) + assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) err = ds.RecordStatisticsSent(ctx) require.NoError(t, err) @@ -332,6 +340,10 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { {Version: "", NumEnrolled: 1}, }, stats.HostsEnrolledByOperatingSystem[""]) assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors)) + assert.Equal(t, false, stats.AIFeaturesDisabled) + assert.Equal(t, false, stats.MaintenanceWindowsEnabled) + assert.Equal(t, false, stats.MaintenanceWindowsConfigured) + assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) // Create multiple new sessions for a single user _, err = ds.NewSession(ctx, u1.ID, "session_key2") @@ -366,6 +378,10 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysActual) assert.Equal(t, 0, stats.NumWeeklyPolicyViolationDaysPossible) assert.Equal(t, `[{"count":10,"loc":["a","b","c"]}]`, string(stats.StoredErrors)) + assert.Equal(t, false, stats.AIFeaturesDisabled) + assert.Equal(t, false, stats.MaintenanceWindowsEnabled) + assert.Equal(t, false, stats.MaintenanceWindowsConfigured) + assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) // Add host to test hosts not responding stats _, err = ds.NewHost(ctx, &fleet.Host{ diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index c25b4a791c..c0d1b8b61b 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -106,7 +106,14 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team) func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) + // Delete team policies first, because policies can have associated installers which may be deleted on cascade + // before deleting the policies (which are also deleted on cascade). + _, err := tx.ExecContext(ctx, `DELETE FROM policies WHERE team_id = ?`, tid) + if err != nil { + return ctxerr.Wrapf(ctx, err, "deleting policies for team %d", tid) + } + + _, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) if err != nil { return ctxerr.Wrapf(ctx, err, "delete team %d", tid) } diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 5fdc6a9082..f3940eda31 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -33,8 +33,8 @@ import ( "github.com/go-kit/log" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) const ( @@ -235,14 +235,14 @@ func setupRealReplica(t testing.TB, testName string, ds *Datastore, options *dbO t.Cleanup( func() { - // Stop slave + // Stop replica if out, err := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, "-e", - "STOP SLAVE; RESET SLAVE ALL;", + "STOP REPLICA; RESET REPLICA ALL;", ).CombinedOutput(); err != nil { t.Log(err) t.Log(string(out)) @@ -262,45 +262,54 @@ func setupRealReplica(t testing.TB, testName string, ds *Datastore, options *dbO _, err = ds.primary.ExecContext(ctx, "FLUSH PRIVILEGES") require.NoError(t, err) - // Retrieve master binary log coordinates - ms, err := ds.MasterStatus(ctx) - require.NoError(t, err) - - // Get MySQL version var version string err = ds.primary.GetContext(ctx, &version, "SELECT VERSION()") require.NoError(t, err) - using57 := strings.HasPrefix(version, "5.7") - extraMasterOptions := "" - if !using57 { - extraMasterOptions = "GET_MASTER_PUBLIC_KEY=1," // needed for MySQL 8.0 caching_sha2_password authentication - } + + // Retrieve master binary log coordinates + ms, err := ds.MasterStatus(ctx, version) + require.NoError(t, err) mu.Lock() databasesToReplicate = strings.TrimPrefix(databasesToReplicate+fmt.Sprintf(", `%s`", testName), ",") mu.Unlock() - // Configure slave and start replication + setSourceStmt := fmt.Sprintf(` + CHANGE REPLICATION SOURCE TO + GET_SOURCE_PUBLIC_KEY=1, + SOURCE_HOST='mysql_test', + SOURCE_USER='%s', + SOURCE_PASSWORD='%s', + SOURCE_LOG_FILE='%s', + SOURCE_LOG_POS=%d + `, replicaUser, replicaPassword, ms.File, ms.Position) + if strings.HasPrefix(version, "8.0") { + setSourceStmt = fmt.Sprintf(` + CHANGE MASTER TO + GET_MASTER_PUBLIC_KEY=1, + MASTER_HOST='mysql_test', + MASTER_USER='%s', + MASTER_PASSWORD='%s', + MASTER_LOG_FILE='%s', + MASTER_LOG_POS=%d + `, replicaUser, replicaPassword, ms.File, ms.Position) + } + + // Configure replica and start replication if out, err := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, "-e", fmt.Sprintf( ` - STOP SLAVE; - RESET SLAVE ALL; + STOP REPLICA; + RESET REPLICA ALL; CHANGE REPLICATION FILTER REPLICATE_DO_DB = ( %s ); - CHANGE MASTER TO - %s - MASTER_HOST='mysql_test', - MASTER_USER='%s', - MASTER_PASSWORD='%s', - MASTER_LOG_FILE='%s', - MASTER_LOG_POS=%d; - START SLAVE; - `, databasesToReplicate, extraMasterOptions, replicaUser, replicaPassword, ms.File, ms.Position, + %s; + START REPLICA; + `, databasesToReplicate, setSourceStmt, ), ).CombinedOutput(); err != nil { t.Error(err) @@ -348,7 +357,7 @@ func initializeDatabase(t testing.TB, testName string, opts *DatastoreTestOption ) cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, @@ -369,7 +378,7 @@ func initializeDatabase(t testing.TB, testName string, opts *DatastoreTestOption ) cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_replica_test", + "docker", "compose", "exec", "-T", "mysql_replica_test", // Command run inside container "mysql", "-u"+testUsername, "-p"+testPassword, @@ -454,7 +463,7 @@ func CreateMySQLDSWithReplica(t *testing.T, opts *DatastoreTestOptions) *Datasto ds = createMySQLDSWithOptions(t, opts) status, err := ds.ReplicaStatus(context.Background()) require.NoError(t, err) - if status["Replica_SQL_Running"] != "Yes" && status["Slave_SQL_Running"] != "Yes" { + if status["Replica_SQL_Running"] != "Yes" { t.Logf("create replica attempt: %d replica status: %+v", attempt, status) if lastErr, ok := status["Last_Error"]; ok && lastErr != "" { t.Logf("replica not running after attempt %d; Last_Error: %s", attempt, lastErr) @@ -486,6 +495,7 @@ func CreateNamedMySQLDS(t *testing.T, name string) *Datastore { } func ExecAdhocSQL(tb testing.TB, ds *Datastore, fn func(q sqlx.ExtContext) error) { + tb.Helper() err := fn(ds.primary) require.NoError(tb, err) } @@ -494,6 +504,12 @@ func ExecAdhocSQLWithError(ds *Datastore, fn func(q sqlx.ExtContext) error) erro return fn(ds.primary) } +// EncryptWithPrivateKey encrypts data with the server private key associated +// with the Datastore. +func EncryptWithPrivateKey(tb testing.TB, ds *Datastore, data []byte) ([]byte, error) { + return encrypt(data, ds.serverPrivateKey) +} + // TruncateTables truncates the specified tables, in order, using ds.writer. // Note that the order is typically not important because FK checks are // disabled while truncating. If no table is provided, all tables (except @@ -674,7 +690,7 @@ func GetAggregatedStats(ctx context.Context, ds *Datastore, aggregate fleet.Aggr func SetOrderedCreatedAtTimestamps(t testing.TB, ds *Datastore, afterTime time.Time, table, keyCol string, keys ...any) time.Time { now := afterTime for i := 0; i < len(keys); i++ { - now = afterTime.Add(time.Second) + now = now.Add(time.Second) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(context.Background(), fmt.Sprintf(`UPDATE %s SET created_at=? WHERE %s=?`, table, keyCol), now, keys[i]) @@ -684,7 +700,83 @@ func SetOrderedCreatedAtTimestamps(t testing.TB, ds *Datastore, afterTime time.T return now } -func SetTestABMAssets(t testing.TB, ds *Datastore) { +func CreateABMKeyCertIfNotExists(t testing.TB, ds *Datastore) { + certPEM, keyPEM, _, err := GenerateTestABMAssets(t) + require.NoError(t, err) + var assets []fleet.MDMConfigAsset + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ + fleet.MDMAssetABMKey, + }) + if err != nil { + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + assets = append(assets, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMKey, Value: keyPEM}) + } + + _, err = ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ + fleet.MDMAssetABMCert, + }) + if err != nil { + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + assets = append(assets, fleet.MDMConfigAsset{Name: fleet.MDMAssetABMCert, Value: certPEM}) + } + + if len(assets) != 0 { + err = ds.InsertMDMConfigAssets(context.Background(), assets) + require.NoError(t, err) + } +} + +// CreateAndSetABMToken creates a new ABM token (using an existing ABM key/cert) and stores it in the DB. +func CreateAndSetABMToken(t testing.TB, ds *Datastore, orgName string) *fleet.ABMToken { + assets, err := ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ + fleet.MDMAssetABMKey, + fleet.MDMAssetABMCert, + }) + require.NoError(t, err) + + certPEM := assets[fleet.MDMAssetABMCert].Value + + testBMToken := &nanodep_client.OAuth1Tokens{ + ConsumerKey: "test_consumer", + ConsumerSecret: "test_secret", + AccessToken: "test_access_token", + AccessSecret: "test_access_secret", + AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + } + + rawToken, err := json.Marshal(testBMToken) + require.NoError(t, err) + + smimeToken := fmt.Sprintf( + "Content-Type: text/plain;charset=UTF-8\r\n"+ + "Content-Transfer-Encoding: 7bit\r\n"+ + "\r\n%s", rawToken, + ) + + block, _ := pem.Decode(certPEM) + require.NotNil(t, block) + require.Equal(t, "CERTIFICATE", block.Type) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + encryptedToken, err := pkcs7.Encrypt([]byte(smimeToken), []*x509.Certificate{cert}) + require.NoError(t, err) + + tokenBytes := fmt.Sprintf( + "Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\r\n"+ + "Content-Transfer-Encoding: base64\r\n"+ + "Content-Disposition: attachment; filename=\"smime.p7m\"\r\n"+ + "Content-Description: S/MIME Encrypted Message\r\n"+ + "\r\n%s", base64.StdEncoding.EncodeToString(encryptedToken)) + + tok, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{EncryptedToken: []byte(tokenBytes), OrganizationName: orgName}) + require.NoError(t, err) + return tok +} + +func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMToken { apnsCert, apnsKey, err := GenerateTestCertBytes() require.NoError(t, err) @@ -693,7 +785,6 @@ func SetTestABMAssets(t testing.TB, ds *Datastore) { assets := []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetABMCert, Value: certPEM}, {Name: fleet.MDMAssetABMKey, Value: keyPEM}, - {Name: fleet.MDMAssetABMToken, Value: tokenBytes}, {Name: fleet.MDMAssetAPNSCert, Value: apnsCert}, {Name: fleet.MDMAssetAPNSKey, Value: apnsKey}, {Name: fleet.MDMAssetCACert, Value: certPEM}, @@ -703,12 +794,17 @@ func SetTestABMAssets(t testing.TB, ds *Datastore) { err = ds.InsertMDMConfigAssets(context.Background(), assets) require.NoError(t, err) + tok, err := ds.InsertABMToken(context.Background(), &fleet.ABMToken{EncryptedToken: tokenBytes, OrganizationName: orgName}) + require.NoError(t, err) + appCfg, err := ds.AppConfig(context.Background()) require.NoError(t, err) appCfg.MDM.EnabledAndConfigured = true appCfg.MDM.AppleBMEnabledAndConfigured = true err = ds.SaveAppConfig(context.Background(), appCfg) require.NoError(t, err) + + return tok } func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) { @@ -793,10 +889,15 @@ type MasterStatus struct { Position uint64 } -func (ds *Datastore) MasterStatus(ctx context.Context) (MasterStatus, error) { - rows, err := ds.writer(ctx).Query("SHOW MASTER STATUS") +func (ds *Datastore) MasterStatus(ctx context.Context, mysqlVersion string) (MasterStatus, error) { + stmt := "SHOW BINARY LOG STATUS" + if strings.HasPrefix(mysqlVersion, "8.0") { + stmt = "SHOW MASTER STATUS" + } + + rows, err := ds.writer(ctx).Query(stmt) if err != nil { - return MasterStatus{}, ctxerr.Wrap(ctx, err, "show master status") + return MasterStatus{}, ctxerr.Wrap(ctx, err, stmt) } defer rows.Close() @@ -841,7 +942,7 @@ func (ds *Datastore) MasterStatus(ctx context.Context) (MasterStatus, error) { } func (ds *Datastore) ReplicaStatus(ctx context.Context) (map[string]interface{}, error) { - rows, err := ds.reader(ctx).QueryContext(ctx, "SHOW SLAVE STATUS") + rows, err := ds.reader(ctx).QueryContext(ctx, "SHOW REPLICA STATUS") if err != nil { return nil, ctxerr.Wrap(ctx, err, "show replica status") } diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 72bc53ecd7..1127497f88 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -1,15 +1,22 @@ package mysql import ( + "cmp" "context" "database/sql" + "encoding/base64" + "encoding/json" "errors" "fmt" + "slices" "strings" + "time" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) @@ -20,21 +27,24 @@ SELECT vap.platform, vap.name, vap.latest_version, + vat.self_service, NULLIF(vap.icon_url, '') AS icon_url FROM vpp_apps vap INNER JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform WHERE - vap.title_id = ? AND - vat.global_or_team_id = ?` + vap.title_id = ? %s` - var tmID uint + // when team id is not nil, we need to filter by the global or team id given. + args := []any{titleID} + teamFilter := "" if teamID != nil { - tmID = *teamID + args = append(args, *teamID) + teamFilter = "AND vat.global_or_team_id = ?" } var app fleet.VPPAppStoreApp - err := sqlx.GetContext(ctx, ds.reader(ctx), &app, query, titleID, tmID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &app, fmt.Sprintf(query, teamFilter), args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("VPPApp"), "get VPP app metadata") @@ -46,7 +56,8 @@ WHERE } func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *uint, appID fleet.VPPAppID) (*fleet.VPPAppStatusSummary, - error) { + error, +) { var dest fleet.VPPAppStatusSummary stmt := fmt.Sprintf(` @@ -90,9 +101,9 @@ WHERE "mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged, "mdm_status_error": fleet.MDMAppleStatusError, "mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError, - "software_status_pending": fleet.SoftwareInstallerPending, - "software_status_failed": fleet.SoftwareInstallerFailed, - "software_status_installed": fleet.SoftwareInstallerInstalled, + "software_status_pending": fleet.SoftwareInstallPending, + "software_status_failed": fleet.SoftwareInstallFailed, + "software_status_installed": fleet.SoftwareInstalled, }) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get summary host vpp installs: named query") @@ -152,19 +163,20 @@ func (ds *Datastore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPAp }) } -func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []fleet.VPPAppID) error { +func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets []fleet.VPPAppTeam) error { existingApps, err := ds.GetAssignedVPPApps(ctx, teamID) if err != nil { return ctxerr.Wrap(ctx, err, "SetTeamVPPApps getting list of existing apps") } - var missingApps []fleet.VPPAppID + var toAddApps []fleet.VPPAppTeam var toRemoveApps []fleet.VPPAppID for existingApp := range existingApps { var found bool - for _, adamID := range appIDs { - if adamID == existingApp { + for _, appFleet := range appFleets { + // Self service value doesn't matter for removing app from team + if existingApp == appFleet.VPPAppID { found = true } } @@ -173,15 +185,23 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs [] } } - for _, adamID := range appIDs { - if _, ok := existingApps[adamID]; !ok { - missingApps = append(missingApps, adamID) + for _, appFleet := range appFleets { + if existingFleet, ok := existingApps[appFleet.VPPAppID]; !ok || existingFleet.SelfService != appFleet.SelfService { + toAddApps = append(toAddApps, appFleet) + } + } + + var vppToken *fleet.VPPTokenDB + if len(appFleets) > 0 { + vppToken, err = ds.GetVPPTokenByTeamID(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps retrieve VPP token ID") } } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - for _, toAdd := range missingApps { - if err := insertVPPAppTeams(ctx, tx, toAdd, teamID); err != nil { + for _, toAdd := range toAddApps { + if err := insertVPPAppTeams(ctx, tx, toAdd, teamID, vppToken.ID); err != nil { return ctxerr.Wrap(ctx, err, "SetTeamVPPApps inserting vpp app into team") } } @@ -197,7 +217,12 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs [] } func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) { - err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + vppToken, err := ds.GetVPPTokenByTeamID(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam unable to get VPP Token ID") + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { titleID, err := ds.getOrInsertSoftwareTitleForVPPApp(ctx, tx, app) if err != nil { return err @@ -209,23 +234,23 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPApps transaction") } - if err := insertVPPAppTeams(ctx, tx, app.VPPAppID, teamID); err != nil { + if err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppToken.ID); err != nil { return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPAppTeams transaction") } return nil }) if err != nil { - return nil, err + return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam") } return app, nil } -func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]struct{}, error) { +func (ds *Datastore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) { stmt := ` SELECT - adam_id, platform + adam_id, platform, self_service FROM vpp_apps_teams vat WHERE @@ -236,14 +261,14 @@ WHERE tmID = *teamID } - var results []fleet.VPPAppID + var results []fleet.VPPAppTeam if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, tmID); err != nil { return nil, ctxerr.Wrap(ctx, err, "get assigned VPP apps") } - appSet := make(map[fleet.VPPAppID]struct{}) + appSet := make(map[fleet.VPPAppID]fleet.VPPAppTeam) for _, r := range results { - appSet[r] = struct{}{} + appSet[r.VPPAppID] = r } return appSet, nil @@ -277,12 +302,13 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "insert VPP apps") } -func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppID, teamID *uint) error { +func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) error { stmt := ` INSERT INTO vpp_apps_teams - (adam_id, global_or_team_id, team_id, platform) + (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id) VALUES - (?, ?, ?, ?) + (?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE self_service = VALUES(self_service) ` var globalOrTmID uint @@ -294,10 +320,10 @@ VALUES } } - _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform) + _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID) if IsDuplicate(err) { err = &existsError{ - Identifier: fmt.Sprintf("%s %s", appID.AdamID, appID.Platform), + Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService), TeamID: teamID, ResourceType: "VPPAppID", } @@ -373,7 +399,7 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s }, ) if err != nil { - return 0, err + return 0, ctxerr.Wrap(ctx, err, "optimistic get or insert VPP app") } return titleID, nil @@ -399,7 +425,7 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, app return nil } -func (ds *Datastore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) { +func (ds *Datastore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { stmt := ` SELECT va.adam_id, @@ -409,7 +435,8 @@ SELECT va.title_id, va.platform, va.created_at, - va.updated_at + va.updated_at, + vat.self_service FROM vpp_apps va JOIN vpp_apps_teams vat ON va.adam_id = vat.adam_id AND va.platform = vat.platform WHERE vat.global_or_team_id = ? AND va.title_id = ? @@ -432,17 +459,23 @@ WHERE vat.global_or_team_id = ? AND va.title_id = ? return &dest, nil } -func (ds *Datastore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, appID fleet.VPPAppID, - commandUUID, associatedEventID string) error { +func (ds *Datastore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, appID fleet.VPPAppID, + commandUUID, associatedEventID string, selfService bool, +) error { stmt := ` INSERT INTO host_vpp_software_installs - (host_id, adam_id, platform, command_uuid, user_id, associated_event_id) + (host_id, adam_id, platform, command_uuid, user_id, associated_event_id, self_service) VALUES - (?,?,?,?,?,?) + (?,?,?,?,?,?,?) ` + var userID *uint + if ctxUser := authz.UserFromContext(ctx); ctxUser != nil { + userID = &ctxUser.ID + } + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, appID.AdamID, appID.Platform, commandUUID, userID, - associatedEventID); err != nil { + associatedEventID, selfService); err != nil { return ctxerr.Wrap(ctx, err, "insert into host_vpp_software_installs") } @@ -463,7 +496,8 @@ SELECT hdn.display_name AS host_display_name, st.name AS software_title, hvsi.adam_id AS app_store_id, - hvsi.command_uuid AS command_uuid + hvsi.command_uuid AS command_uuid, + hvsi.self_service AS self_service FROM host_vpp_software_installs hvsi LEFT OUTER JOIN users u ON hvsi.user_id = u.id @@ -475,20 +509,21 @@ WHERE ` type result struct { - HostID uint `db:"host_id"` - HostDisplayName string `db:"host_display_name"` - SoftwareTitle string `db:"software_title"` - AppStoreID string `db:"app_store_id"` - CommandUUID string `db:"command_uuid"` - UserName string `db:"user_name"` - UserID uint `db:"user_id"` - UserEmail string `db:"user_email"` + HostID uint `db:"host_id"` + HostDisplayName string `db:"host_display_name"` + SoftwareTitle string `db:"software_title"` + AppStoreID string `db:"app_store_id"` + CommandUUID string `db:"command_uuid"` + UserName *string `db:"user_name"` + UserID *uint `db:"user_id"` + UserEmail *string `db:"user_email"` + SelfService bool `db:"self_service"` } listStmt, args, err := sqlx.Named(stmt, map[string]any{ "command_uuid": commandResults.CommandUUID, - "software_status_failed": string(fleet.SoftwareInstallerFailed), - "software_status_installed": string(fleet.SoftwareInstallerInstalled), + "software_status_failed": string(fleet.SoftwareInstallFailed), + "software_status_installed": string(fleet.SoftwareInstalled), }) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args") @@ -503,23 +538,26 @@ WHERE return nil, nil, ctxerr.Wrap(ctx, err, "select past activity data for VPP app install") } - user := &fleet.User{ - ID: res.UserID, - Name: res.UserName, - Email: res.UserEmail, + var user *fleet.User + if res.UserID != nil { + user = &fleet.User{ + ID: *res.UserID, + Name: *res.UserName, + Email: *res.UserEmail, + } } var status string switch commandResults.Status { case fleet.MDMAppleStatusAcknowledged: - status = string(fleet.SoftwareInstallerInstalled) + status = string(fleet.SoftwareInstalled) case fleet.MDMAppleStatusCommandFormatError: case fleet.MDMAppleStatusError: - status = string(fleet.SoftwareInstallerFailed) + status = string(fleet.SoftwareInstallFailed) default: // This case shouldn't happen (we should only be doing this check if the command is in a // "terminal" state, but adding it so we have a default - status = string(fleet.SoftwareInstallerPending) + status = string(fleet.SoftwareInstallPending) } act := &fleet.ActivityInstalledAppStoreApp{ @@ -528,8 +566,597 @@ WHERE SoftwareTitle: res.SoftwareTitle, AppStoreID: res.AppStoreID, CommandUUID: res.CommandUUID, + SelfService: res.SelfService, Status: status, } return user, act, nil } + +func (ds *Datastore) GetVPPTokenByLocation(ctx context.Context, loc string) (*fleet.VPPTokenDB, error) { + stmt := `SELECT id FROM vpp_tokens WHERE location = ?` + var tokenID uint + if err := sqlx.GetContext(ctx, ds.reader(ctx), &tokenID, stmt, loc); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("VPPToken"), "retrieve vpp token by location") + } + return nil, ctxerr.Wrap(ctx, err, "retrieve vpp token by location") + } + return ds.GetVPPToken(ctx, tokenID) +} + +func (ds *Datastore) InsertVPPToken(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + insertStmt := ` + INSERT INTO + vpp_tokens ( + organization_name, + location, + renew_at, + token + ) + VALUES (?, ?, ?, ?) +` + + vppTokenDB, err := vppTokenDataToVppTokenDB(ctx, tok) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "translating vpp token to db representation") + } + + tokEnc, err := encrypt([]byte(vppTokenDB.Token), ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "encrypt token with datastore.serverPrivateKey") + } + + res, err := ds.writer(ctx).ExecContext( + ctx, + insertStmt, + vppTokenDB.OrgName, + vppTokenDB.Location, + vppTokenDB.RenewDate, + tokEnc, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "inserting vpp token") + } + + id, _ := res.LastInsertId() + + vppTokenDB.ID = uint(id) + + return vppTokenDB, nil +} + +func (ds *Datastore) UpdateVPPToken(ctx context.Context, tokenID uint, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + stmt := ` + UPDATE vpp_tokens + SET + organization_name = ?, + location = ?, + renew_at = ?, + token = ? + WHERE + id = ? +` + + vppTokenDB, err := vppTokenDataToVppTokenDB(ctx, tok) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "translating vpp token to db representation") + } + + tokEnc, err := encrypt([]byte(vppTokenDB.Token), ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "encrypt token with datastore.serverPrivateKey") + } + + _, err = ds.writer(ctx).ExecContext( + ctx, + stmt, + vppTokenDB.OrgName, + vppTokenDB.Location, + vppTokenDB.RenewDate, + tokEnc, + tokenID, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "inserting vpp token") + } + + return ds.GetVPPToken(ctx, tokenID) +} + +func vppTokenDataToVppTokenDB(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + tokRawBytes, err := base64.StdEncoding.DecodeString(tok.Token) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decoding raw vpp token data") + } + + var tokRaw fleet.VPPTokenRaw + if err := json.Unmarshal(tokRawBytes, &tokRaw); err != nil { + return nil, ctxerr.Wrap(ctx, err, "unmarshalling raw vpp token") + } + + exp, err := time.Parse(fleet.VPPTimeFormat, tokRaw.ExpDate) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "parsing vpp token expiration date") + } + exp = exp.UTC() + + vppTokenDB := &fleet.VPPTokenDB{ + OrgName: tokRaw.OrgName, + Location: tok.Location, + RenewDate: exp, + Token: tok.Token, + } + + return vppTokenDB, nil +} + +func (ds *Datastore) GetVPPToken(ctx context.Context, tokenID uint) (*fleet.VPPTokenDB, error) { + stmt := ` + SELECT + id, + organization_name, + location, + renew_at, + token + FROM + vpp_tokens v + WHERE + id = ? +` + stmtTeams := ` + SELECT + vt.team_id, + vt.null_team_type, + COALESCE(t.name, '') AS name + FROM + vpp_token_teams vt + LEFT OUTER JOIN + teams t + ON t.id = vt.team_id + WHERE + vpp_token_id = ? +` + + var tokEnc fleet.VPPTokenDB + + var tokTeams []struct { + TeamID *uint `db:"team_id"` + NullTeam fleet.NullTeamType `db:"null_team_type"` + Name string `db:"name"` + } + + if err := sqlx.GetContext(ctx, ds.reader(ctx), &tokEnc, stmt, tokenID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("VPPToken"), "selecting vpp token from db") + } + return nil, ctxerr.Wrap(ctx, err, "selecting vpp token from db") + } + + tokDec, err := decrypt([]byte(tokEnc.Token), ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypting vpp token with serverPrivateKey") + } + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &tokTeams, stmtTeams, tokenID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "selecting vpp token teams from db") + } + + tok := &fleet.VPPTokenDB{ + ID: tokEnc.ID, + OrgName: tokEnc.OrgName, + Location: tokEnc.Location, + RenewDate: tokEnc.RenewDate, + Token: string(tokDec), + } + + if tokTeams == nil { + // Not assigned, no need to loop over teams + return tok, nil + } + +TEAMLOOP: + for _, team := range tokTeams { + switch team.NullTeam { + case fleet.NullTeamAllTeams: + // This should only be possible if there are no other teams + // Make sure something array is non-nil + if len(tokTeams) != 1 { + return nil, ctxerr.Errorf(ctx, "team \"%s\" belongs to All teams, and %d other team(s)", tok.OrgName, len(tokTeams)-1) + } + tok.Teams = []fleet.TeamTuple{} + break TEAMLOOP + case fleet.NullTeamNoTeam: + tok.Teams = append(tok.Teams, fleet.TeamTuple{ + ID: 0, + Name: fleet.TeamNameNoTeam, + }) + case fleet.NullTeamNone: + // Regular team + tok.Teams = append(tok.Teams, fleet.TeamTuple{ + ID: *team.TeamID, + Name: team.Name, + }) + } + } + + return tok, nil +} + +func (ds *Datastore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { + stmtTeamName := `SELECT name FROM teams WHERE id = ?` + stmtRemove := `DELETE FROM vpp_token_teams WHERE vpp_token_id = ?` + stmtInsert := ` + INSERT INTO + vpp_token_teams ( + vpp_token_id, + team_id, + null_team_type + ) VALUES ` + stmtValues := `(?, ?, ?)` + // Delete all apps associated with a token if we change its team + stmtDeleteApps := `DELETE FROM vpp_apps_teams WHERE vpp_token_id = ?` + deleteArgs := []any{id} + + var values string + var args []any + // No DB constraint for null_team_type, if no team or all teams + // comes up we have to check it in go + var nullTeamCheck fleet.NullTeamType + + if len(teams) > 0 { + for _, team := range teams { + team := team + if values == "" { + values = stmtValues + } else { + values = strings.Join([]string{values, stmtValues}, ",") + } + var teamptr *uint + nullTeam := fleet.NullTeamNone + if team != 0 { + // Regular team + teamptr = &team + } else { + // NoTeam team + nullTeam = fleet.NullTeamNoTeam + nullTeamCheck = fleet.NullTeamNoTeam + } + args = append(args, id, teamptr, nullTeam) + } + } else if teams != nil { + // Empty but not nil, All Teams! + values = stmtValues + args = append(args, id, nil, fleet.NullTeamAllTeams) + nullTeamCheck = fleet.NullTeamAllTeams + } + + stmtInsertFull := stmtInsert + values + + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // NOTE This is not optimal, and has the potential to + // introduce race conditions. Ideally we would insert and + // check the constraints in a single query. + if err := checkVPPNullTeam(ctx, tx, &id, nullTeamCheck); err != nil { + return ctxerr.Wrap(ctx, err, "vpp token null team check") + } + + if _, err := tx.ExecContext(ctx, stmtDeleteApps, deleteArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "deleting old vpp team apps associations") + } + + if _, err := tx.ExecContext(ctx, stmtRemove, id); err != nil { + return ctxerr.Wrap(ctx, err, "removing old vpp team associations") + } + + if len(args) > 0 { + if _, err := tx.ExecContext(ctx, stmtInsertFull, args...); err != nil { + if isChildForeignKeyError(err) { + return foreignKey("team", fmt.Sprintf("(team_id)=(%v)", values)) + } + + return ctxerr.Wrap(ctx, err, "updating vpp token team") + } + } + + return nil + }) + if err != nil { + var mysqlErr *mysql.MySQLError + // https://dev.mysql.com/doc/mysql-errors/8.4/en/server-error-reference.html#error_er_dup_entry + if errors.As(err, &mysqlErr) && IsDuplicate(err) { + var dupeTeamID uint + var dupeTeamName string + fmt.Sscanf(mysqlErr.Message, "Duplicate entry '%d' for", &dupeTeamID) + if err := sqlx.GetContext(ctx, ds.reader(ctx), &dupeTeamName, stmtTeamName, dupeTeamID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting team name for vpp token conflict error") + } + return nil, ctxerr.Wrap(ctx, fleet.ErrVPPTokenTeamConstraint{Name: dupeTeamName, ID: &dupeTeamID}) + } + return nil, ctxerr.Wrap(ctx, err, "modifying vpp token team associations") + } + + return ds.GetVPPToken(ctx, id) +} + +func (ds *Datastore) DeleteVPPToken(ctx context.Context, tokenID uint) error { + _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM vpp_tokens WHERE id = ?`, tokenID) + if err != nil { + return ctxerr.Wrap(ctx, err, "deleting vpp token") + } + + return nil +} + +func (ds *Datastore) ListVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + // linter false positive on the word "token" (gosec G101) + //nolint:gosec + stmtTokens := ` + SELECT + id, + organization_name, + location, + renew_at, + token + FROM + vpp_tokens v +` + + stmtTeams := ` + SELECT + vt.id, + vt.vpp_token_id, + vt.team_id, + vt.null_team_type, + COALESCE(t.name, '') AS name + FROM + vpp_token_teams vt + LEFT OUTER JOIN + teams t + ON vt.team_id = t.id +` + var tokEncs []fleet.VPPTokenDB + + var teams []struct { + ID string `db:"id"` + VPPTokenID uint `db:"vpp_token_id"` + TeamID *uint `db:"team_id"` + TeamName string `db:"name"` + NullTeam fleet.NullTeamType `db:"null_team_type"` + } + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &tokEncs, stmtTokens); err != nil { + return nil, ctxerr.Wrap(ctx, err, "selecting vpp tokens from db") + } + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &teams, stmtTeams); err != nil { + return nil, ctxerr.Wrap(ctx, err, "selecting vpp token teams from db") + } + + tokens := map[uint]*fleet.VPPTokenDB{} + + for _, tokEnc := range tokEncs { + tokDec, err := decrypt([]byte(tokEnc.Token), ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypting vpp token with serverPrivateKey") + } + + tokens[tokEnc.ID] = &fleet.VPPTokenDB{ + ID: tokEnc.ID, + OrgName: tokEnc.OrgName, + Location: tokEnc.Location, + RenewDate: tokEnc.RenewDate, + Token: string(tokDec), + } + } + + for _, team := range teams { + token := tokens[team.VPPTokenID] + if token.Teams != nil && len(token.Teams) == 0 { + // Token was already assigned to All Teams, we should not + // see it again in a loop + return nil, fmt.Errorf("vpp token \"%s\" has been assigned to All teams, and another team", token.OrgName) + } + switch team.NullTeam { + case fleet.NullTeamAllTeams: + // All teams, there should be no other teams. + // Make sure array is non-nil + if token.Teams != nil { + // This team has already been assigned something, this + // should not happen + return nil, fmt.Errorf("vpp token \"%s\" has been asssigned to All teams, and another team", token.OrgName) + } + token.Teams = []fleet.TeamTuple{} + case fleet.NullTeamNoTeam: + token.Teams = append(token.Teams, fleet.TeamTuple{ID: 0, Name: fleet.TeamNameNoTeam}) + case fleet.NullTeamNone: + // Regular team + token.Teams = append(token.Teams, fleet.TeamTuple{ID: *team.TeamID, Name: team.TeamName}) + } + } + + var outTokens []*fleet.VPPTokenDB + for _, token := range tokens { + outTokens = append(outTokens, token) + } + + slices.SortFunc(outTokens, func(a, b *fleet.VPPTokenDB) int { + return cmp.Compare(a.OrgName, b.OrgName) + }) + + return outTokens, nil +} + +func (ds *Datastore) GetVPPTokenByTeamID(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + stmtTeam := ` + SELECT + v.id, + v.organization_name, + v.location, + v.renew_at, + v.token + FROM + vpp_token_teams vt + INNER JOIN + vpp_tokens v + ON vt.vpp_token_id = v.id + WHERE + vt.team_id = ? +` + stmtTeamNames := ` + SELECT + vt.team_id, + vt.null_team_type, + COALESCE(t.name, '') AS name + FROM + vpp_token_teams vt + LEFT OUTER JOIN + teams t + ON t.id = vt.team_id + WHERE + vt.vpp_token_id = ? +` + stmtNullTeam := ` + SELECT + v.id, + v.organization_name, + v.location, + v.renew_at, + v.token + FROM + vpp_tokens v + INNER JOIN + vpp_token_teams vt + ON v.id = vt.vpp_token_id + WHERE + vt.team_id IS NULL + AND + vt.null_team_type = ? +` + + var tokEnc fleet.VPPTokenDB + + var tokTeams []struct { + TeamID *uint `db:"team_id"` + NullTeam fleet.NullTeamType `db:"null_team_type"` + Name string `db:"name"` + } + + var err error + if teamID != nil && *teamID != 0 { + err = sqlx.GetContext(ctx, ds.reader(ctx), &tokEnc, stmtTeam, teamID) + } else { + err = sqlx.GetContext(ctx, ds.reader(ctx), &tokEnc, stmtNullTeam, fleet.NullTeamNoTeam) + } + if err != nil { + if errors.Is(sql.ErrNoRows, err) { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &tokEnc, stmtNullTeam, fleet.NullTeamAllTeams); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("VPPToken"), "retrieving vpp token by team") + } + return nil, ctxerr.Wrap(ctx, err, "retrieving vpp token by team") + } + } else { + return nil, ctxerr.Wrap(ctx, err, "retrieving vpp token by team") + } + } + + tokDec, err := decrypt([]byte(tokEnc.Token), ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "decrypting vpp token with serverPrivateKey") + } + + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &tokTeams, stmtTeamNames, tokEnc.ID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "retrieving vpp token team information") + } + + tok := &fleet.VPPTokenDB{ + ID: tokEnc.ID, + OrgName: tokEnc.OrgName, + Location: tokEnc.Location, + RenewDate: tokEnc.RenewDate, + Token: string(tokDec), + } + + if tokTeams == nil { + // Not assigned, no need to loop over teams + return tok, nil + } + +TEAMLOOP: + for _, team := range tokTeams { + switch team.NullTeam { + case fleet.NullTeamAllTeams: + // This should only be possible if there are no other teams + // Make sure something array is non-nil + if len(tokTeams) != 1 { + return nil, ctxerr.Errorf(ctx, "team \"%s\" belongs to All teams, and %d other team(s)", tok.OrgName, len(tokTeams)-1) + } + tok.Teams = []fleet.TeamTuple{} + break TEAMLOOP + case fleet.NullTeamNoTeam: + tok.Teams = append(tok.Teams, fleet.TeamTuple{ + ID: 0, + Name: fleet.TeamNameNoTeam, + }) + case fleet.NullTeamNone: + // Regular team + tok.Teams = append(tok.Teams, fleet.TeamTuple{ + ID: *team.TeamID, + Name: team.Name, + }) + } + } + + return tok, nil +} + +func checkVPPNullTeam(ctx context.Context, tx sqlx.ExtContext, currentID *uint, nullTeam fleet.NullTeamType) error { + nullTeamStmt := `SELECT vpp_token_id FROM vpp_token_teams WHERE null_team_type = ?` + anyTeamStmt := `SELECT vpp_token_id FROM vpp_token_teams WHERE null_team_type = 'allteams' OR null_team_type = 'noteam' OR team_id IS NOT NULL` + + if nullTeam == fleet.NullTeamAllTeams { + var ids []uint + if err := sqlx.SelectContext(ctx, tx, &ids, anyTeamStmt); err != nil { + return ctxerr.Wrap(ctx, err, "scanning row in check vpp token null team") + } + + if len(ids) > 0 { + if len(ids) > 1 { + return ctxerr.Wrap(ctx, errors.New("Cannot assign token to All teams, other teams have tokens")) + } + if currentID == nil || ids[0] != *currentID { + return ctxerr.Wrap(ctx, errors.New("Cannot assign token to All teams, other teams have tokens")) + } + } + } + + var id uint + allTeamsFound := true + if err := sqlx.GetContext(ctx, tx, &id, nullTeamStmt, fleet.NullTeamAllTeams); err != nil { + if errors.Is(err, sql.ErrNoRows) { + allTeamsFound = false + } else { + return ctxerr.Wrap(ctx, err, "scanning row in check vpp token null team") + } + } + + if allTeamsFound && currentID != nil && *currentID != id { + return ctxerr.Wrap(ctx, fleet.ErrVPPTokenTeamConstraint{Name: fleet.ReservedNameAllTeams}) + } + + if nullTeam != fleet.NullTeamNone { + var id uint + if err := sqlx.GetContext(ctx, tx, &id, nullTeamStmt, nullTeam); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return ctxerr.Wrap(ctx, err, "scanning row in check vpp token null team") + } + if currentID == nil || *currentID != id { + return ctxerr.Wrap(ctx, fleet.ErrVPPTokenTeamConstraint{Name: nullTeam.PrettyName()}) + } + } + + return nil +} diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 27fce609d3..9a6c6796ce 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -3,10 +3,13 @@ package mysql import ( "context" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" @@ -25,6 +28,8 @@ func TestVPP(t *testing.T) { {"VPPAppStatus", testVPPAppStatus}, {"VPPApps", testVPPApps}, {"GetVPPAppByTeamAndTitleID", testGetVPPAppByTeamAndTitleID}, + {"VPPTokensCRUD", testVPPTokensCRUD}, + {"VPPTokenAppTeamAssociations", testVPPTokenAppTeamAssociations}, } for _, c := range cases { @@ -47,6 +52,8 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotNil(t, team2) + test.CreateInsertGlobalVPPToken(t, ds) + // get for non-existing title meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, 1) require.Error(t, err) @@ -55,8 +62,10 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Nil(t, meta) // create no-team app - va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) + va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) vpp1, titleID1 := va1.VPPAppID, va1.TitleID @@ -65,16 +74,23 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1}, meta) - // try to add the same app again, fails - var existsErr *existsError - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) - require.Error(t, err) - require.ErrorAs(t, err, &existsErr) + // try to add the same app again, update self_service field + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}, SelfService: true}, + }, nil) + require.NoError(t, err) + + // get no-team app + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID1) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp1", VPPAppID: vpp1, SelfService: true}, meta) // create team1 app - va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, &team1.ID) + va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.app.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, + }, &team1.ID) require.NoError(t, err) vpp2, titleID2 := va2.VPPAppID, va2.TitleID @@ -83,11 +99,22 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + // get it for all teams + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, titleID2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + // try to add the same app again, fails - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, &team1.ID) - require.Error(t, err) - require.ErrorAs(t, err, &existsErr) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.app.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}, SelfService: true}, + }, &team1.ID) + require.NoError(t, err) + + // get it for team 1 + meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) + require.NoError(t, err) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true}, meta) // get it for team 2, does not exist meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) @@ -96,21 +123,25 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.Nil(t, meta) // create the same app for team2 - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, &team2.ID) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.app.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, + }, &team2.ID) require.NoError(t, err) // get it for team 1 and team 2, both work meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team1.ID, titleID2) require.NoError(t, err) - require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) + require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2, SelfService: true}, meta) meta, err = ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &team2.ID, titleID2) require.NoError(t, err) require.Equal(t, &fleet.VPPAppStoreApp{Name: "vpp2", VPPAppID: vpp2}, meta) // create another no-team app - va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, nil) + va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp3", BundleIdentifier: "com.app.vpp3", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) vpp3, titleID3 := va3.VPPAppID, va3.TitleID @@ -184,21 +215,31 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotNil(t, team1) + test.CreateInsertGlobalVPPToken(t, ds) + // create some apps, one for no-team, one for team1, and one in both - va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp1", BundleIdentifier: "com.app.vpp1", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, nil) + va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp1", BundleIdentifier: "com.app.vpp1", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) vpp1 := va1.VPPAppID - va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp2", BundleIdentifier: "com.app.vpp2", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, &team1.ID) + va2, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp2", BundleIdentifier: "com.app.vpp2", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_2", Platform: fleet.MacOSPlatform}}, + }, &team1.ID) require.NoError(t, err) vpp2 := va2.VPPAppID - va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, nil) + va3, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp3", BundleIdentifier: "com.app.vpp3", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, + }, nil) require.NoError(t, err) vpp3 := va3.VPPAppID - _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{Name: "vpp3", BundleIdentifier: "com.app.vpp3", - VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, &team1.ID) + _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "vpp3", BundleIdentifier: "com.app.vpp3", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_3", Platform: fleet.MacOSPlatform}}, + }, &team1.ID) require.NoError(t, err) // for now they all return zeroes @@ -275,6 +316,7 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.Equal(t, user.ID, actUser.ID) require.Equal(t, user.Name, actUser.Name) require.Equal(t, cmd3, act.CommandUUID) + require.False(t, act.SelfService) summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, nil, vpp1) require.NoError(t, err) @@ -317,6 +359,19 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { summary, err = ds.GetSummaryHostVPPAppInstalls(ctx, &team1.ID, vpp3) require.NoError(t, err) require.Equal(t, &fleet.VPPAppStatusSummary{Pending: 0, Failed: 0, Installed: 1}, summary) + + // simulate a self-service request + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `UPDATE host_vpp_software_installs SET self_service = true, user_id = NULL WHERE command_uuid = ?`, + cmd3) + return err + }) + actUser, act, err = ds.GetPastActivityDataForVPPAppInstall(ctx, &mdm.CommandResults{CommandUUID: cmd3}) + require.NoError(t, err) + require.Nil(t, actUser) + require.Equal(t, cmd3, act.CommandUUID) + require.True(t, act.SelfService) } // simulates creating the VPP app install request on the host, returns the command UUID. @@ -355,6 +410,8 @@ func testVPPApps(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "foobar"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + // create a host with some non-VPP software h1, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "macos-test-1", @@ -376,8 +433,8 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.NoError(t, err) // Insert some VPP apps for the team, "vpp_app_1" should match the existing "foo" title - app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b1"} - app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b2"} + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"} _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team.ID) require.NoError(t, err) @@ -385,10 +442,14 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.NoError(t, err) // Insert some VPP apps for no team - appNoTeam1 := &fleet.VPPApp{Name: "vpp_no_team_app_1", VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}, - BundleIdentifier: "b3"} - appNoTeam2 := &fleet.VPPApp{Name: "vpp_no_team_app_2", VPPAppID: fleet.VPPAppID{AdamID: "4", Platform: fleet.MacOSPlatform}, - BundleIdentifier: "b4"} + appNoTeam1 := &fleet.VPPApp{ + Name: "vpp_no_team_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, + BundleIdentifier: "b3", + } + appNoTeam2 := &fleet.VPPApp{ + Name: "vpp_no_team_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "4", Platform: fleet.MacOSPlatform}}, + BundleIdentifier: "b4", + } _, err = ds.InsertVPPAppWithTeam(ctx, appNoTeam1, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, appNoTeam2, nil) @@ -402,43 +463,47 @@ func testVPPApps(t *testing.T, ds *Datastore) { GlobalRole: ptr.String(fleet.RoleAdmin), }) require.NoError(t, err) - err = ds.InsertHostVPPSoftwareInstall(ctx, 1, u.ID, app1.VPPAppID, "a", "b") + ctx = viewer.NewContext(ctx, viewer.Viewer{User: u}) + err = ds.InsertHostVPPSoftwareInstall(ctx, 1, app1.VPPAppID, "a", "b", false) require.NoError(t, err) - err = ds.InsertHostVPPSoftwareInstall(ctx, 2, u.ID, app2.VPPAppID, "c", "d") + err = ds.InsertHostVPPSoftwareInstall(ctx, 2, app2.VPPAppID, "c", "d", true) require.NoError(t, err) var results []struct { HostID uint `db:"host_id"` - UserID uint `db:"user_id"` + UserID *uint `db:"user_id"` AdamID string `db:"adam_id"` CommandUUID string `db:"command_uuid"` AssociatedEventID string `db:"associated_event_id"` + SelfService bool `db:"self_service"` } - err = sqlx.SelectContext(ctx, ds.reader(ctx), &results, `SELECT host_id, user_id, adam_id, command_uuid, associated_event_id FROM host_vpp_software_installs ORDER BY adam_id`) + err = sqlx.SelectContext(ctx, ds.reader(ctx), &results, `SELECT host_id, user_id, adam_id, command_uuid, associated_event_id, self_service FROM host_vpp_software_installs ORDER BY adam_id`) require.NoError(t, err) require.Len(t, results, 2) a1 := results[0] a2 := results[1] require.Equal(t, a1.HostID, uint(1)) - require.Equal(t, a1.UserID, u.ID) + require.Equal(t, a1.UserID, ptr.Uint(u.ID)) require.Equal(t, a1.AdamID, app1.AdamID) require.Equal(t, a1.CommandUUID, "a") require.Equal(t, a1.AssociatedEventID, "b") + require.False(t, a1.SelfService) require.Equal(t, a2.HostID, uint(2)) - require.Equal(t, a2.UserID, u.ID) + require.Equal(t, a2.UserID, ptr.Uint(u.ID)) require.Equal(t, a2.AdamID, app2.AdamID) require.Equal(t, a2.CommandUUID, "c") require.Equal(t, a2.AssociatedEventID, "d") + require.True(t, a2.SelfService) // Check that getting the assigned apps works appSet, err := ds.GetAssignedVPPApps(ctx, &team.ID) require.NoError(t, err) - assert.Equal(t, map[fleet.VPPAppID]struct{}{app1.VPPAppID: {}, app2.VPPAppID: {}}, appSet) + assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{app1.VPPAppID: {VPPAppID: app1.VPPAppID}, app2.VPPAppID: {VPPAppID: app2.VPPAppID}}, appSet) appSet, err = ds.GetAssignedVPPApps(ctx, nil) require.NoError(t, err) - assert.Equal(t, map[fleet.VPPAppID]struct{}{appNoTeam1.VPPAppID: {}, appNoTeam2.VPPAppID: {}}, appSet) + assert.Equal(t, map[fleet.VPPAppID]fleet.VPPAppTeam{appNoTeam1.VPPAppID: {VPPAppID: appNoTeam1.VPPAppID}, appNoTeam2.VPPAppID: {VPPAppID: appNoTeam2.VPPAppID}}, appSet) var appTitles []fleet.SoftwareTitle err = sqlx.SelectContext(ctx, ds.reader(ctx), &appTitles, `SELECT name, bundle_identifier FROM software_titles WHERE bundle_identifier IN (?,?) ORDER BY bundle_identifier`, app1.BundleIdentifier, app2.BundleIdentifier) @@ -457,17 +522,24 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "vpp gang"}) require.NoError(t, err) + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + // Insert some VPP apps for the team - app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b1"} + app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) require.NoError(t, err) - app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b2"} + app2 := &fleet.VPPApp{Name: "vpp_app_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "2", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2"} _, err = ds.InsertVPPAppWithTeam(ctx, app2, nil) require.NoError(t, err) - app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b3"} + app3 := &fleet.VPPApp{Name: "vpp_app_3", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b3"} _, err = ds.InsertVPPAppWithTeam(ctx, app3, nil) require.NoError(t, err) - app4 := &fleet.VPPApp{Name: "vpp_app_4", VPPAppID: fleet.VPPAppID{AdamID: "4", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b4"} + app4 := &fleet.VPPApp{Name: "vpp_app_4", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "4", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b4"} _, err = ds.InsertVPPAppWithTeam(ctx, app4, nil) require.NoError(t, err) @@ -476,7 +548,10 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Len(t, assigned, 0) // Assign 2 apps - err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppID{app1.VPPAppID, app2.VPPAppID}) + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID}, + {VPPAppID: app2.VPPAppID, SelfService: true}, + }) require.NoError(t, err) assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) @@ -484,9 +559,14 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Len(t, assigned, 2) assert.Contains(t, assigned, app1.VPPAppID) assert.Contains(t, assigned, app2.VPPAppID) + assert.True(t, assigned[app2.VPPAppID].SelfService) // Assign an additional app - err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppID{app1.VPPAppID, app2.VPPAppID, app3.VPPAppID}) + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID}, + {VPPAppID: app2.VPPAppID}, + {VPPAppID: app3.VPPAppID}, + }) require.NoError(t, err) assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) @@ -495,9 +575,14 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Contains(t, assigned, app1.VPPAppID) require.Contains(t, assigned, app2.VPPAppID) require.Contains(t, assigned, app3.VPPAppID) + assert.False(t, assigned[app2.VPPAppID].SelfService) // Swap one app out for another - err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppID{app1.VPPAppID, app2.VPPAppID, app4.VPPAppID}) + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{ + {VPPAppID: app1.VPPAppID}, + {VPPAppID: app2.VPPAppID, SelfService: true}, + {VPPAppID: app4.VPPAppID}, + }) require.NoError(t, err) assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) @@ -506,9 +591,10 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { require.Contains(t, assigned, app1.VPPAppID) require.Contains(t, assigned, app2.VPPAppID) require.Contains(t, assigned, app4.VPPAppID) + assert.True(t, assigned[app2.VPPAppID].SelfService) // Remove all apps - err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppID{}) + err = ds.SetTeamVPPApps(ctx, &team.ID, []fleet.VPPAppTeam{}) require.NoError(t, err) assigned, err = ds.GetAssignedVPPApps(ctx, &team.ID) @@ -521,33 +607,597 @@ func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + var nfe fleet.NotFoundError fooApp, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "foo", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b1", Name: "Foo"}, + &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "foo", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1", Name: "Foo"}, &team.ID) require.NoError(t, err) fooTitleID := fooApp.TitleID - gotVPPApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, fooTitleID, true) + gotVPPApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, fooTitleID) require.NoError(t, err) require.Equal(t, "foo", gotVPPApp.AdamID) require.Equal(t, fooTitleID, gotVPPApp.TitleID) // title that doesn't exist - gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, 999, true) + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, 999) require.ErrorAs(t, err, &nfe) // create an entry for the global team barApp, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppID: fleet.VPPAppID{AdamID: "bar", Platform: fleet.MacOSPlatform}, BundleIdentifier: "b2", Name: "Bar"}, nil) + &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "bar", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b2", Name: "Bar"}, nil) require.NoError(t, err) barTitleID := barApp.TitleID // not found providing the team id - gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, barTitleID, true) + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, &team.ID, barTitleID) require.ErrorAs(t, err, &nfe) // found for the global team - gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, nil, barTitleID, true) + gotVPPApp, err = ds.GetVPPAppByTeamAndTitleID(ctx, nil, barTitleID) require.NoError(t, err) require.Equal(t, "bar", gotVPPApp.AdamID) require.Equal(t, barTitleID, gotVPPApp.TitleID) } + +func testVPPTokensCRUD(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "Kritters"}) + assert.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Zingers"}) + assert.NoError(t, err) + + tokens, err := ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, tokens, 0) + + orgName := "Donkey Kong" + location := "Jungle" + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName, location) + require.NoError(t, err) + + orgName2 := "Diddy Kong" + location2 := "Mines" + dataToken2, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName2, location2) + require.NoError(t, err) + + orgName3 := "Cranky Cong" + location3 := "Cranky's Cabin" + dataToken3, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName3, location3) + require.NoError(t, err) + + orgName4 := "Funky Kong" + location4 := "Funky's Fishing Shack" + dataToken4, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName4, location4) + require.NoError(t, err) + + orgName5 := "Lanky Kong" + location5 := "Lanky Kong's Pool" + dataToken5, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName5, location5) + require.NoError(t, err) + + orgName6 := "Dixie Kong" + location6 := "Dixie's Island" + dataToken6, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), orgName6, location6) + require.NoError(t, err) + + // No assignments / disabled token + tok, err := ds.InsertVPPToken(ctx, dataToken) + tokID := tok.ID + assert.NoError(t, err) + assert.Equal(t, dataToken.Location, tok.Location) + assert.Equal(t, dataToken.Token, tok.Token) + assert.Equal(t, orgName, tok.OrgName) + assert.Equal(t, location, tok.Location) + assert.Nil(t, tok.Teams) // No team assigned + + tok, err = ds.GetVPPToken(ctx, tokID) + assert.NoError(t, err) + assert.Equal(t, tokID, tok.ID) + assert.Equal(t, dataToken.Location, tok.Location) + assert.Equal(t, dataToken.Token, tok.Token) + assert.Equal(t, orgName, tok.OrgName) + assert.Equal(t, location, tok.Location) + assert.Nil(t, tok.Teams) + + toks, err := ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + assert.Equal(t, tokID, toks[0].ID) + assert.Equal(t, dataToken.Location, toks[0].Location) + assert.Equal(t, dataToken.Token, toks[0].Token) + assert.Equal(t, orgName, toks[0].OrgName) + assert.Equal(t, location, toks[0].Location) + assert.Nil(t, toks[0].Teams) + + teamTok, err := ds.GetVPPTokenByTeamID(ctx, nil) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + assert.Nil(t, teamTok) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + assert.Nil(t, teamTok) + + // Assign to all teams + upTok, err := ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{}) + assert.NoError(t, err) + assert.Equal(t, tokID, upTok.ID) + assert.Equal(t, dataToken.Location, upTok.Location) + assert.Equal(t, dataToken.Token, upTok.Token) + assert.Equal(t, orgName, upTok.OrgName) + assert.Equal(t, location, upTok.Location) + assert.NotNil(t, upTok.Teams) // "All Teams" teamm array is non-nil but empty + assert.Len(t, upTok.Teams, 0) + + tok, err = ds.GetVPPToken(ctx, tok.ID) + assert.NoError(t, err) + assert.Equal(t, tokID, tok.ID) + assert.Equal(t, dataToken.Location, tok.Location) + assert.Equal(t, dataToken.Token, tok.Token) + assert.Equal(t, orgName, tok.OrgName) + assert.Equal(t, location, tok.Location) + assert.NotNil(t, tok.Teams) // "All Teams" teams array is non-nil but empty + assert.Len(t, tok.Teams, 0) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + assert.Equal(t, dataToken.Location, toks[0].Location) + assert.Equal(t, dataToken.Token, toks[0].Token) + assert.Equal(t, orgName, toks[0].OrgName) + assert.Equal(t, location, toks[0].Location) + assert.NotNil(t, toks[0].Teams) + assert.Len(t, toks[0].Teams, 0) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokID, teamTok.ID) + assert.Equal(t, dataToken.Location, teamTok.Location) + assert.Equal(t, dataToken.Token, teamTok.Token) + assert.Equal(t, orgName, teamTok.OrgName) + assert.Equal(t, location, teamTok.Location) + assert.NotNil(t, teamTok.Teams) + assert.Len(t, teamTok.Teams, 0) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.NoError(t, err) + assert.Equal(t, tokID, teamTok.ID) + assert.Equal(t, dataToken.Location, teamTok.Location) + assert.Equal(t, dataToken.Token, teamTok.Token) + assert.Equal(t, orgName, teamTok.OrgName) + assert.Equal(t, location, teamTok.Location) + assert.NotNil(t, teamTok.Teams) + assert.Len(t, teamTok.Teams, 0) + + // Assign to team "No Team" + upTok, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{0}) + require.NoError(t, err) + assert.Len(t, upTok.Teams, 1) + assert.Equal(t, tokID, upTok.ID) + assert.Equal(t, uint(0), upTok.Teams[0].ID) + assert.Equal(t, fleet.TeamNameNoTeam, upTok.Teams[0].Name) + + tok, err = ds.GetVPPToken(ctx, tok.ID) + assert.NoError(t, err) + assert.Len(t, tok.Teams, 1) + assert.Equal(t, tokID, tok.ID) + assert.Equal(t, uint(0), tok.Teams[0].ID) + assert.Equal(t, fleet.TeamNameNoTeam, tok.Teams[0].Name) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + assert.Len(t, toks[0].Teams, 1) + assert.Equal(t, tokID, toks[0].ID) + assert.Equal(t, uint(0), toks[0].Teams[0].ID) + assert.Equal(t, fleet.TeamNameNoTeam, toks[0].Teams[0].Name) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokID, teamTok.ID) + assert.Equal(t, dataToken.Location, teamTok.Location) + assert.Equal(t, dataToken.Token, teamTok.Token) + assert.Equal(t, orgName, teamTok.OrgName) + assert.Equal(t, location, teamTok.Location) + assert.Len(t, teamTok.Teams, 1) + assert.Equal(t, uint(0), teamTok.Teams[0].ID) + assert.Equal(t, fleet.TeamNameNoTeam, teamTok.Teams[0].Name) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + // Assign to normal team + upTok, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{team.ID}) + assert.NoError(t, err) + assert.Len(t, upTok.Teams, 1) + assert.Equal(t, team.ID, upTok.Teams[0].ID) + assert.Equal(t, team.Name, upTok.Teams[0].Name) + + tok, err = ds.GetVPPToken(ctx, tok.ID) + assert.NoError(t, err) + assert.Len(t, tok.Teams, 1) + assert.Equal(t, team.ID, tok.Teams[0].ID) + assert.Equal(t, team.Name, tok.Teams[0].Name) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + assert.Len(t, toks[0].Teams, 1) + assert.Equal(t, team.ID, toks[0].Teams[0].ID) + assert.Equal(t, team.Name, toks[0].Teams[0].Name) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.NoError(t, err) + assert.Equal(t, tokID, teamTok.ID) + assert.Equal(t, dataToken.Location, teamTok.Location) + assert.Equal(t, dataToken.Token, teamTok.Token) + assert.Equal(t, orgName, teamTok.OrgName) + assert.Equal(t, location, teamTok.Location) + assert.NotNil(t, teamTok.Teams) + assert.Len(t, teamTok.Teams, 1) + assert.Equal(t, team.ID, teamTok.Teams[0].ID) + assert.Equal(t, team.Name, teamTok.Teams[0].Name) + + // Renew flow + upTok, err = ds.UpdateVPPToken(ctx, tokID, dataToken6) + assert.NoError(t, err) + assert.Equal(t, tokID, upTok.ID) + assert.Equal(t, dataToken6.Location, upTok.Location) + assert.Equal(t, dataToken6.Token, upTok.Token) + assert.Equal(t, orgName6, upTok.OrgName) + assert.Equal(t, location6, upTok.Location) + assert.NotNil(t, upTok.Teams) + assert.Len(t, upTok.Teams, 1) + assert.Equal(t, team.ID, upTok.Teams[0].ID) + assert.Equal(t, team.Name, upTok.Teams[0].Name) + + tok, err = ds.GetVPPToken(ctx, tok.ID) + assert.NoError(t, err) + assert.Equal(t, tokID, tok.ID) + assert.Equal(t, dataToken6.Location, tok.Location) + assert.Equal(t, dataToken6.Token, tok.Token) + assert.Equal(t, orgName6, tok.OrgName) + assert.Equal(t, location6, tok.Location) + assert.NotNil(t, tok.Teams) + assert.Len(t, tok.Teams, 1) + assert.Equal(t, team.ID, tok.Teams[0].ID) + assert.Equal(t, team.Name, tok.Teams[0].Name) + + // Assign back to no team / disabled + upTok, err = ds.UpdateVPPTokenTeams(ctx, tokID, nil) + assert.NoError(t, err) + assert.Nil(t, upTok.Teams) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + teamTok, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + // Delete + err = ds.DeleteVPPToken(ctx, tokID) + assert.NoError(t, err) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 0) + + // Multiple tokens and constraints tests + tokNone, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokNone.ID, nil) + assert.NoError(t, err) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 1) + + _, err = ds.InsertVPPToken(ctx, dataToken) + assert.Error(t, err) + + tokAll, err := ds.InsertVPPToken(ctx, dataToken2) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.NoError(t, err) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 2) + + // Remove tokAll from All teams + tokAll, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, nil) + assert.NoError(t, err) + + tokTeam, err := ds.InsertVPPToken(ctx, dataToken3) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID}) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID, team.ID}) + assert.Error(t, err) + + // Cannot move tokAll to all teams now + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.Error(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{0}) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.Error(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID}) + assert.NoError(t, err) + + /// + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 3) + + tokTeams, err := ds.InsertVPPToken(ctx, dataToken4) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeams.ID, []uint{team.ID, team2.ID}) + assert.Error(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeams.ID, []uint{team2.ID}) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeams.ID, []uint{team.ID, team2.ID}) + assert.Error(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeams.ID, []uint{team.ID, 0}) + assert.Error(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeams.ID, []uint{team2.ID, 0}) + assert.NoError(t, err) + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 4) + + tokTeams, err = ds.GetVPPToken(ctx, tokTeams.ID) + assert.NoError(t, err) + assert.Len(t, tokTeams.Teams, 2) + assert.Contains(t, tokTeams.Teams, fleet.TeamTuple{ID: team2.ID, Name: team2.Name}) + assert.Contains(t, tokTeams.Teams, fleet.TeamTuple{ID: 0, Name: fleet.TeamNameNoTeam}) + + tokBadConstraint, err := ds.InsertVPPToken(ctx, dataToken5) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokBadConstraint.ID, []uint{}) + assert.Error(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokBadConstraint.ID, []uint{team.ID}) + assert.Error(t, err) + assert.ErrorContains(t, err, "\"Kritters\" team already has a VPP token.") + _, err = ds.UpdateVPPTokenTeams(ctx, tokBadConstraint.ID, []uint{0}) + assert.Error(t, err) + assert.ErrorContains(t, err, "\"No team\" team already has a VPP token.") + + toks, err = ds.ListVPPTokens(ctx) + assert.NoError(t, err) + assert.Len(t, toks, 5) + + /// + tokNil, err := ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokTeams.ID, tokNil.ID) + + tokTeam1, err := ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.NoError(t, err) + assert.Equal(t, tokTeam.ID, tokTeam1.ID) + + tokTeam2, err := ds.GetVPPTokenByTeamID(ctx, &team2.ID) + assert.NoError(t, err) + assert.Equal(t, tokTeam2.ID, tokTeam2.ID) + assert.Len(t, tokTeam2.Teams, 2) + assert.Contains(t, tokTeam2.Teams, fleet.TeamTuple{ID: team2.ID, Name: team2.Name}) + assert.Contains(t, tokTeam2.Teams, fleet.TeamTuple{ID: 0, Name: fleet.TeamNameNoTeam}) + + //// + err = ds.DeleteVPPToken(ctx, tokTeam.ID) + assert.NoError(t, err) + + tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokTeams.ID, tokNil.ID) + + tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.Error(t, err) + + tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) + assert.NoError(t, err) + assert.Equal(t, tokTeams.ID, tokTeam2.ID) + + //// + err = ds.DeleteVPPToken(ctx, tokTeams.ID) + assert.NoError(t, err) + + tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) + + //// + tokAll, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.NoError(t, err) + + tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokNil.ID) + + tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokTeam1.ID) + + tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokTeam2.ID) + + err = ds.DeleteVPPToken(ctx, tokAll.ID) + assert.NoError(t, err) + + //// + _, err = ds.UpdateVPPTokenTeams(ctx, tokNone.ID, []uint{0, team.ID, team2.ID}) + assert.NoError(t, err) + + tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) + assert.NoError(t, err) + assert.Equal(t, tokNone.ID, tokNil.ID) + + tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) + assert.NoError(t, err) + assert.Equal(t, tokNone.ID, tokTeam1.ID) + + tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) + assert.NoError(t, err) + assert.Equal(t, tokNone.ID, tokTeam2.ID) + + //// + err = ds.DeleteVPPToken(ctx, tokNone.ID) + assert.NoError(t, err) + +} + +func testVPPTokenAppTeamAssociations(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Kritters"}) + assert.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Zingers"}) + assert.NoError(t, err) + + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + + dataToken2, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Diddy Kong", "Mines") + require.NoError(t, err) + + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + + tok2, err := ds.InsertVPPToken(ctx, dataToken2) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{team1.ID}) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok2.ID, []uint{team2.ID}) + assert.NoError(t, err) + + app1 := &fleet.VPPApp{ + Name: "app1", + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "1", + Platform: fleet.MacOSPlatform, + }, + }, + BundleIdentifier: "app1", + } + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + assert.NoError(t, err) + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team2.ID) + assert.NoError(t, err) + + app2 := &fleet.VPPApp{ + Name: "app2", + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.MacOSPlatform, + }, + }, + BundleIdentifier: "app2", + } + vppApp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + _ = vppApp2 + assert.NoError(t, err) + + // team1: token 1, app1, app2 + // team2: token 2, app 1 + + apps, err := ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 2) + assert.Contains(t, apps, app1.VPPAppID) + assert.Contains(t, apps, app2.VPPAppID) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + /// Try to move team 1 token to team 2 + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{team2.ID}) + assert.Error(t, err) + + // team1: token 1, app1, app2 + // team2: token 2, app 1 + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 2) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, nil) + assert.NoError(t, err) + + // team1: no token, no apps + // team2: token 2, app 1 + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + // Move team 2 token to team 1 + + _, err = ds.UpdateVPPTokenTeams(ctx, tok2.ID, []uint{team1.ID}) + assert.NoError(t, err) + + // team1: token 2, app 1 + // team2: no token, no apps + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + /// Can't assaign apps with no token + + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team2.ID) + assert.Error(t, err) +} diff --git a/server/datastore/mysql/vulnerabilities.go b/server/datastore/mysql/vulnerabilities.go index db63bac01b..81a26ea58b 100644 --- a/server/datastore/mysql/vulnerabilities.go +++ b/server/datastore/mysql/vulnerabilities.go @@ -3,6 +3,7 @@ package mysql import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -496,3 +497,12 @@ func (ds *Datastore) batchInsertHostCounts(ctx context.Context, counts []hostCou return nil } + +func (ds *Datastore) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + var count uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &count, "SELECT 1 FROM cve_meta WHERE cve = ?", cve) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + return count > 0, nil +} diff --git a/server/datastore/s3/bootstrap_package.go b/server/datastore/s3/bootstrap_package.go new file mode 100644 index 0000000000..28a41f51ef --- /dev/null +++ b/server/datastore/s3/bootstrap_package.go @@ -0,0 +1,25 @@ +package s3 + +import "github.com/fleetdm/fleet/v4/server/config" + +const bootstrapPackagePrefix = "bootstrap-packages" + +type BootstrapPackageStore struct { + *commonFileStore +} + +// NewBootstrapPackageStore creates a new instance with the given S3 config. +func NewBootstrapPackageStore(config config.S3Config) (*BootstrapPackageStore, error) { + // bootstrap packages use the same S3 config as software installers + s3store, err := newS3store(config.SoftwareInstallersToInternalCfg()) + if err != nil { + return nil, err + } + return &BootstrapPackageStore{ + &commonFileStore{ + s3store: s3store, + pathPrefix: bootstrapPackagePrefix, + fileLabel: "bootstrap package", + }, + }, nil +} diff --git a/server/datastore/s3/bootstrap_package_test.go b/server/datastore/s3/bootstrap_package_test.go new file mode 100644 index 0000000000..11c4b1fe61 --- /dev/null +++ b/server/datastore/s3/bootstrap_package_test.go @@ -0,0 +1,149 @@ +package s3 + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "path" + "testing" + "time" + + "github.com/aws/aws-sdk-go/service/s3" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestBootstrapPackage(t *testing.T) { + ctx := context.Background() + store := SetupTestBootstrapPackageStore(t, "bootstrap-packages-unit-test", "prefix") + + // get a non-existing package + blob, length, err := store.Get(ctx, "no-such-package") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + require.Nil(t, blob) + require.Zero(t, length) + + exists, err := store.Exists(ctx, "no-such-package") + require.NoError(t, err) + require.False(t, exists) + + createPackageAndHash := func() ([]byte, string) { + b := make([]byte, 1024) + _, err = rand.Read(b) + require.NoError(t, err) + + h := sha256.New() + _, err = h.Write(b) + require.NoError(t, err) + fileID := hex.EncodeToString(h.Sum(nil)) + + return b, fileID + } + + getAndCheck := func(fileID string, expected []byte) { + rc, sz, err := store.Get(ctx, fileID) + require.NoError(t, err) + require.EqualValues(t, len(expected), sz) + defer rc.Close() + + got, err := io.ReadAll(rc) + require.NoError(t, err) + require.Equal(t, expected, got) + + exists, err := store.Exists(ctx, fileID) + require.NoError(t, err) + require.True(t, exists) + } + + // store a package + b0, id0 := createPackageAndHash() + err = store.Put(ctx, id0, bytes.NewReader(b0)) + require.NoError(t, err) + + // read it back, it should match + getAndCheck(id0, b0) + + // store another one + b1, id1 := createPackageAndHash() + err = store.Put(ctx, id1, bytes.NewReader(b1)) + require.NoError(t, err) + + // read it back, it should match + getAndCheck(id1, b1) + + // replace the first one + err = store.Put(ctx, id0, bytes.NewReader(b0)) + require.NoError(t, err) + + // read it back, it should still match + getAndCheck(id0, b0) +} + +func TestBootstrapPackageCleanup(t *testing.T) { + ctx := context.Background() + store := SetupTestBootstrapPackageStore(t, "bootstrap-packages-unit-test", "prefix") + + assertExisting := func(want []string) { + prefix := path.Join(store.prefix, bootstrapPackagePrefix) + page, err := store.s3client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: &store.bucket, + Prefix: &prefix, + }) + require.NoError(t, err) + + got := make([]string, 0, len(page.Contents)) + for _, item := range page.Contents { + got = append(got, path.Base(*item.Key)) + } + require.ElementsMatch(t, want, got) + } + + // cleanup an empty store + n, err := store.Cleanup(ctx, nil, time.Now()) + require.NoError(t, err) + require.Equal(t, 0, n) + + // put a package + ins0 := uuid.NewString() + err = store.Put(ctx, ins0, bytes.NewReader([]byte("package0"))) + require.NoError(t, err) + + // cleanup but mark it as used + n, err = store.Cleanup(ctx, []string{ins0}, time.Now()) + require.NoError(t, err) + require.Equal(t, 0, n) + + assertExisting([]string{ins0}) + + // cleanup but mark it as unused + n, err = store.Cleanup(ctx, []string{}, time.Now()) + require.NoError(t, err) + require.Equal(t, 1, n) + + assertExisting(nil) + + // put a few packages + packages := []string{uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()} + for i, ins := range packages { + err = store.Put(ctx, ins, bytes.NewReader([]byte("package"+fmt.Sprint(i)))) + require.NoError(t, err) + } + + // cleanup with a time in the past, nothing gets removed + n, err = store.Cleanup(ctx, []string{}, time.Now().Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 0, n) + assertExisting([]string{packages[0], packages[1], packages[2], packages[3]}) + + // cleanup in the future, all unused get removed + n, err = store.Cleanup(ctx, []string{packages[0], packages[2]}, time.Now().Add(time.Minute)) + require.NoError(t, err) + require.Equal(t, 2, n) + assertExisting([]string{packages[0], packages[2]}) +} diff --git a/server/datastore/s3/common_file_store.go b/server/datastore/s3/common_file_store.go new file mode 100644 index 0000000000..2a37c741a3 --- /dev/null +++ b/server/datastore/s3/common_file_store.go @@ -0,0 +1,140 @@ +package s3 + +import ( + "context" + "errors" + "io" + "path" + "time" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" +) + +// commonFileStore implements the common Get, Put, Exists and Cleanup +// operations typical for storage of files in the SoftwareInstallers S3 bucket +// configuration. It is used by the SoftwareInstallerStore and the +// BootstrapPackageStore. The only variable thing is the path prefix inside +// the configured bucket, e.g. for software installers it is: +// +// //software-installers/ +// +// and for the bootstrap packages it is: +// +// //bootstrap-packages/ +type commonFileStore struct { + *s3store + pathPrefix string + fileLabel string // how to call the file in error messages +} + +// Get retrieves the requested file from S3. +// It is important that the caller closes the reader when done. +func (s *commonFileStore) Get(ctx context.Context, fileID string) (io.ReadCloser, int64, error) { + key := s.keyForFile(fileID) + + req, err := s.s3client.GetObject(&s3.GetObjectInput{Bucket: &s.bucket, Key: &key}) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + case s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchBucket, "NotFound": + return nil, int64(0), installerNotFoundError{} + } + } + return nil, int64(0), ctxerr.Wrapf(ctx, err, "retrieving %s from S3 store", s.fileLabel) + } + return req.Body, *req.ContentLength, nil +} + +// Put uploads a file to S3. +func (s *commonFileStore) Put(ctx context.Context, fileID string, content io.ReadSeeker) error { + if fileID == "" { + return errors.New("S3 file identifier is empty") + } + + key := s.keyForFile(fileID) + _, err := s.s3client.PutObject(&s3.PutObjectInput{ + Bucket: &s.bucket, + Body: content, + Key: &key, + }) + return err +} + +// Exists checks if a file exists in the S3 bucket for the ID. +func (s *commonFileStore) Exists(ctx context.Context, fileID string) (bool, error) { + key := s.keyForFile(fileID) + + _, err := s.s3client.HeadObject(&s3.HeadObjectInput{Bucket: &s.bucket, Key: &key}) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + case s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchBucket, "NotFound": + return false, nil + } + } + return false, ctxerr.Wrapf(ctx, err, "checking existence of %s in S3 store", s.fileLabel) + } + return true, nil +} + +func (s *commonFileStore) Cleanup(ctx context.Context, usedFileIDs []string, removeCreatedBefore time.Time) (int, error) { + removeCreatedBefore = removeCreatedBefore.UTC() + + usedSet := make(map[string]struct{}, len(usedFileIDs)) + for _, id := range usedFileIDs { + usedSet[id] = struct{}{} + } + + // ListObjectsV2 defaults to a max of 1000 keys, which is sufficient for the + // cleanup task - if more files are present, the next run will get another + // 1000 and will periodically complete the cleanups. + // + // Iterating over all pages would potentially take a long time and would make + // it more likely that a conflict arises, where an unused file becomes used + // again. This approach makes it only two API requests between the read of + // used files and the deletions. + prefix := path.Join(s.prefix, s.pathPrefix) + page, err := s.s3client.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: &s.bucket, + Prefix: &prefix, + }) + if err != nil { + return 0, ctxerr.Wrapf(ctx, err, "listing %s in S3 store", s.fileLabel) + } + + // NOTE: there is an inherent risk that we could delete files that were added + // between the query to list used IDs and now. We minimize that risk by + // checking that the S3 file was created before removeCreatedBefore. + var toDeleteKeys []*s3.ObjectIdentifier + for _, item := range page.Contents { + if item.Key == nil { + continue + } + if _, ok := usedSet[path.Base(*item.Key)]; ok { + continue + } + if item.LastModified == nil || !item.LastModified.UTC().After(removeCreatedBefore) { + // default to doing the cleanup if we don't have the timestamp information + toDeleteKeys = append(toDeleteKeys, &s3.ObjectIdentifier{Key: item.Key}) + } + } + + if len(toDeleteKeys) == 0 { + return 0, nil + } + + res, err := s.s3client.DeleteObjects(&s3.DeleteObjectsInput{ + Bucket: &s.bucket, + Delete: &s3.Delete{ + Objects: toDeleteKeys, + }, + }) + return len(res.Deleted), ctxerr.Wrapf(ctx, err, "deleting %s in S3 store", s.fileLabel) +} + +// keyForFile builds an S3 key to identify the file. +func (s *commonFileStore) keyForFile(fileID string) string { + return path.Join(s.prefix, s.pathPrefix, fileID) +} diff --git a/server/datastore/s3/software_installer.go b/server/datastore/s3/software_installer.go index 5b33d10127..03b390a5c1 100644 --- a/server/datastore/s3/software_installer.go +++ b/server/datastore/s3/software_installer.go @@ -1,22 +1,13 @@ package s3 import ( - "context" - "io" - "path" - - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/service/s3" "github.com/fleetdm/fleet/v4/server/config" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" ) const softwareInstallersPrefix = "software-installers" -// SoftwareInstallerStore implements the fleet.SoftwareInstallerStore to store -// and retrieve software installers from S3. type SoftwareInstallerStore struct { - *s3store + *commonFileStore } // NewSoftwareInstallerStore creates a new instance with the given S3 config. @@ -25,103 +16,11 @@ func NewSoftwareInstallerStore(config config.S3Config) (*SoftwareInstallerStore, if err != nil { return nil, err } - return &SoftwareInstallerStore{s3store}, nil -} - -// Get retrieves the requested software installer from S3. -// It is important that the caller closes the reader when done. -func (i *SoftwareInstallerStore) Get(ctx context.Context, installerID string) (io.ReadCloser, int64, error) { - key := i.keyForInstaller(installerID) - - req, err := i.s3client.GetObject(&s3.GetObjectInput{Bucket: &i.bucket, Key: &key}) - if err != nil { - if aerr, ok := err.(awserr.Error); ok { - switch aerr.Code() { - case s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchBucket, "NotFound": - return nil, int64(0), installerNotFoundError{} - } - } - return nil, int64(0), ctxerr.Wrap(ctx, err, "retrieving software installer from S3 store") - } - return req.Body, *req.ContentLength, nil -} - -// Put uploads a software installer to S3. -func (i *SoftwareInstallerStore) Put(ctx context.Context, installerID string, content io.ReadSeeker) error { - key := i.keyForInstaller(installerID) - _, err := i.s3client.PutObject(&s3.PutObjectInput{ - Bucket: &i.bucket, - Body: content, - Key: &key, - }) - return err -} - -// Exists checks if a software installer exists in the S3 bucket for the ID. -func (i *SoftwareInstallerStore) Exists(ctx context.Context, installerID string) (bool, error) { - key := i.keyForInstaller(installerID) - - _, err := i.s3client.HeadObject(&s3.HeadObjectInput{Bucket: &i.bucket, Key: &key}) - if err != nil { - if aerr, ok := err.(awserr.Error); ok { - switch aerr.Code() { - case s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchBucket, "NotFound": - return false, nil - } - } - return false, ctxerr.Wrap(ctx, err, "checking existence of software installer in S3 store") - } - return true, nil -} - -func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string) (int, error) { - usedSet := make(map[string]struct{}, len(usedInstallerIDs)) - for _, id := range usedInstallerIDs { - usedSet[id] = struct{}{} - } - - // ListObjectsV2 defaults to a max of 1000 keys, which is sufficient for the - // cleanup task - if more software installers are present, the next run will - // get another 1000 and will periodically complete the cleanups. - // - // Iterating over all pages would potentially take a long time and would make - // it more likely that a conflict arises, where an unused software installer - // becomes used again. This approach makes it only two API requests between - // the read of used installers and the deletions. - prefix := path.Join(i.prefix, softwareInstallersPrefix) - page, err := i.s3client.ListObjectsV2(&s3.ListObjectsV2Input{ - Bucket: &i.bucket, - Prefix: &prefix, - }) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "listing software installers in S3 store") - } - - var toDeleteKeys []*s3.ObjectIdentifier - for _, item := range page.Contents { - if item.Key == nil { - continue - } - if _, ok := usedSet[path.Base(*item.Key)]; ok { - continue - } - toDeleteKeys = append(toDeleteKeys, &s3.ObjectIdentifier{Key: item.Key}) - } - - if len(toDeleteKeys) == 0 { - return 0, nil - } - - res, err := i.s3client.DeleteObjects(&s3.DeleteObjectsInput{ - Bucket: &i.bucket, - Delete: &s3.Delete{ - Objects: toDeleteKeys, + return &SoftwareInstallerStore{ + &commonFileStore{ + s3store: s3store, + pathPrefix: softwareInstallersPrefix, + fileLabel: "software installer", }, - }) - return len(res.Deleted), ctxerr.Wrap(ctx, err, "deleting software installers in S3 store") -} - -// keyForInstaller builds an S3 key to identify the software installer. -func (i *SoftwareInstallerStore) keyForInstaller(installerID string) string { - return path.Join(i.prefix, softwareInstallersPrefix, installerID) + }, nil } diff --git a/server/datastore/s3/software_installer_test.go b/server/datastore/s3/software_installer_test.go index 9292a6ed7a..f049121468 100644 --- a/server/datastore/s3/software_installer_test.go +++ b/server/datastore/s3/software_installer_test.go @@ -10,6 +10,7 @@ import ( "io" "path" "testing" + "time" "github.com/aws/aws-sdk-go/service/s3" "github.com/fleetdm/fleet/v4/server/fleet" @@ -104,7 +105,7 @@ func TestSoftwareInstallerCleanup(t *testing.T) { } // cleanup an empty store - n, err := store.Cleanup(ctx, nil) + n, err := store.Cleanup(ctx, nil, time.Now()) require.NoError(t, err) require.Equal(t, 0, n) @@ -114,14 +115,14 @@ func TestSoftwareInstallerCleanup(t *testing.T) { require.NoError(t, err) // cleanup but mark it as used - n, err = store.Cleanup(ctx, []string{ins0}) + n, err = store.Cleanup(ctx, []string{ins0}, time.Now()) require.NoError(t, err) require.Equal(t, 0, n) assertExisting([]string{ins0}) // cleanup but mark it as unused - n, err = store.Cleanup(ctx, []string{}) + n, err = store.Cleanup(ctx, []string{}, time.Now()) require.NoError(t, err) require.Equal(t, 1, n) @@ -134,9 +135,15 @@ func TestSoftwareInstallerCleanup(t *testing.T) { require.NoError(t, err) } - n, err = store.Cleanup(ctx, []string{installers[0], installers[2]}) + // cleanup with a time in the past, nothing gets removed + n, err = store.Cleanup(ctx, []string{}, time.Now().Add(-time.Minute)) + require.NoError(t, err) + require.Equal(t, 0, n) + assertExisting([]string{installers[0], installers[1], installers[2], installers[3]}) + + // cleanup in the future, all unused get removed + n, err = store.Cleanup(ctx, []string{installers[0], installers[2]}, time.Now().Add(time.Minute)) require.NoError(t, err) require.Equal(t, 2, n) - assertExisting([]string{installers[0], installers[2]}) } diff --git a/server/datastore/s3/testing_utils.go b/server/datastore/s3/testing_utils.go index 7854d3b34f..76366031a0 100644 --- a/server/datastore/s3/testing_utils.go +++ b/server/datastore/s3/testing_utils.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/s3" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" @@ -26,6 +27,12 @@ func SetupTestSoftwareInstallerStore(tb testing.TB, bucket, prefix string) *Soft return store } +func SetupTestBootstrapPackageStore(tb testing.TB, bucket, prefix string) *BootstrapPackageStore { + store := setupTestStore(tb, bucket, prefix, NewBootstrapPackageStore) + tb.Cleanup(func() { cleanupStore(tb, store.s3store) }) + return store +} + // SetupTestInstallerStore creates a new store with minio as a back-end // for local testing func SetupTestInstallerStore(tb testing.TB, bucket, prefix string) *InstallerStore { @@ -105,6 +112,12 @@ func cleanupStore(tb testing.TB, store *s3store) { resp, err := store.s3client.ListObjects(&s3.ListObjectsInput{ Bucket: &store.bucket, }) + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == s3.ErrCodeNoSuchBucket { + // fine, nothing to clean-up if the bucket no longer exists, no error + return + } + } require.NoError(tb, err) var objs []*s3.ObjectIdentifier diff --git a/server/docs/patterns.md b/server/docs/patterns.md new file mode 100644 index 0000000000..2249b447dd --- /dev/null +++ b/server/docs/patterns.md @@ -0,0 +1,32 @@ +# Backend patterns + +The backend software patterns that we follow in Fleet. + +> NOTE: There are always exceptions to the rules, but we try to follow these patterns as much as possible unless a specific use case calls +> for something else. These should be discussed within the team and documented before merging. + +## MySQL + +Use high precision for all time fields. Precise timestamps make sure that we can accurately track when records were created and updated, +keep records in order with a reliable sort, and speed up testing by not having to wait for the time to +update. [MySQL reference](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-type-syntax.html). [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703). +Example: + +```sql +CREATE TABLE `sample` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`) +); +``` + +Do not use [goqu](https://github.com/doug-martin/goqu); use MySQL queries directly. Searching for, understanding, and debugging direct MySQL +queries is easier. If needing to modify an existing `goqu` query, try to rewrite it in +MySQL. [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703). + +### Data retention + +Sometimes we need data from rows that have been deleted from DB. For example, the activity feed may be retained forever, and it needs user info (or host info) that may not exist anymore. +Going forward, we need to keep this data in a dedicated table(s). A reference unmerged PR is [here](https://github.com/fleetdm/fleet/pull/17472/files#diff-57a635e42320a87dd15a3ae03d66834f2cbc4fcdb5f3ebb7075d966b96f760afR16). +The `id` may be the same as that of the original table. For example, if the `user` row is deleted, a new entry with the same `user.id` can be added to `user_persistent_info`. diff --git a/server/fleet/activities.go b/server/fleet/activities.go index ccaf96d51e..373aa5b720 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -5,7 +5,7 @@ import ( "encoding/json" ) -//go:generate go run gen_activity_doc.go "../../docs/Using Fleet/Audit-logs.md" +//go:generate go run gen_activity_doc.go "../../docs/Contributing/Audit-logs.md" type ContextKey string @@ -99,7 +99,9 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeResentConfigurationProfile{}, ActivityTypeInstalledSoftware{}, + ActivityTypeUninstalledSoftware{}, ActivityTypeAddedSoftware{}, + ActivityTypeEditedSoftware{}, ActivityTypeDeletedSoftware{}, ActivityEnabledVPP{}, ActivityDisabledVPP{}, @@ -1521,6 +1523,38 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai }` } +type ActivityTypeUninstalledSoftware struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + SoftwareTitle string `json:"software_title"` + ExecutionID string `json:"script_execution_id"` + Status string `json:"status"` +} + +func (a ActivityTypeUninstalledSoftware) ActivityName() string { + return "uninstalled_software" +} + +func (a ActivityTypeUninstalledSoftware) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeUninstalledSoftware) Documentation() (activity, details, detailsExample string) { + return `Generated when a software is uninstalled on a host.`, + `This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. +- "software_title": Name of the software. +- "script_execution_id": ID of the software uninstall script. +- "status": Status of the software uninstallation.`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro", + "software_title": "Falcon.app", + "script_execution_id": "ece8d99d-4313-446a-9af2-e152cd1bad1e", + "status": "uninstalled" +}` +} + type ActivityTypeAddedSoftware struct { SoftwareTitle string `json:"software_title"` SoftwarePackage string `json:"software_package"` @@ -1548,6 +1582,33 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) { }` } +type ActivityTypeEditedSoftware struct { + SoftwareTitle string `json:"software_title"` + SoftwarePackage *string `json:"software_package"` + TeamName *string `json:"team_name"` + TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` +} + +func (a ActivityTypeEditedSoftware) ActivityName() string { + return "edited_software" +} + +func (a ActivityTypeEditedSoftware) Documentation() (string, string, string) { + return `Generated when a software installer is updated in Fleet.`, `This activity contains the following fields: +- "software_title": Name of the software. +- "software_package": Filename of the installer as of this update (including if unchanged). +- "team_name": Name of the team on which this software was updated.` + " `null` " + `if it was updated on no team. +- "team_id": The ID of the team on which this software was updated.` + " `null` " + `if it was updated on no team. +- "self_service": Whether the software is available for installation by the end user.`, `{ + "software_title": "Falcon.app", + "software_package": "FalconSensor-6.44.pkg", + "team_name": "Workstations", + "team_id": 123, + "self_service": true +}` +} + type ActivityTypeDeletedSoftware struct { SoftwareTitle string `json:"software_title"` SoftwarePackage string `json:"software_package"` @@ -1656,24 +1717,34 @@ func LogRoleChangeActivities( return nil } -type ActivityEnabledVPP struct{} +type ActivityEnabledVPP struct { + Location string `json:"location"` +} func (a ActivityEnabledVPP) ActivityName() string { return "enabled_vpp" } func (a ActivityEnabledVPP) Documentation() (activity string, details string, detailsExample string) { - return "Generated when the VPP feature is enabled in Fleet.", "", "" + return "Generated when VPP features are enabled in Fleet.", `This activity contains the following fields: +- "location": Location associated with the VPP content token for the enabled VPP features.`, `{ + "location": "Acme Inc." +}` } -type ActivityDisabledVPP struct{} +type ActivityDisabledVPP struct { + Location string `json:"location"` +} func (a ActivityDisabledVPP) ActivityName() string { return "disabled_vpp" } func (a ActivityDisabledVPP) Documentation() (activity string, details string, detailsExample string) { - return "Generated when the VPP feature is disabled in Fleet.", "", "" + return "Generated when VPP features are disabled in Fleet.", `This activity contains the following fields: +- "location": Location associated with the VPP content token for the disabled VPP features.`, `{ + "location": "Acme Inc." +}` } type ActivityAddedAppStoreApp struct { @@ -1682,6 +1753,7 @@ type ActivityAddedAppStoreApp struct { TeamName *string `json:"team_name"` TeamID *uint `json:"team_id"` Platform AppleDevicePlatform `json:"platform"` + SelfService bool `json:"self_service"` } func (a ActivityAddedAppStoreApp) ActivityName() string { @@ -1693,11 +1765,13 @@ func (a ActivityAddedAppStoreApp) Documentation() (activity string, details stri - "software_title": Name of the App Store app. - "app_store_id": ID of the app on the Apple App Store. - "platform": Platform of the app (` + "`darwin`, `ios`, or `ipados`" + `). +- "self_service": App installation can be initiated by device owner. - "team_name": Name of the team to which this App Store app was added, or ` + "`null`" + ` if it was added to no team. - "team_id": ID of the team to which this App Store app was added, or ` + "`null`" + `if it was added to no team.`, `{ "software_title": "Logic Pro", "app_store_id": "1234567", "platform": "darwin", + "self_service": false, "team_name": "Workstations", "team_id": 1 }` @@ -1737,6 +1811,7 @@ type ActivityInstalledAppStoreApp struct { AppStoreID string `json:"app_store_id"` CommandUUID string `json:"command_uuid"` Status string `json:"status,omitempty"` + SelfService bool `json:"self_service"` } func (a ActivityInstalledAppStoreApp) HostIDs() []uint { @@ -1750,11 +1825,13 @@ func (a ActivityInstalledAppStoreApp) ActivityName() string { func (a ActivityInstalledAppStoreApp) Documentation() (string, string, string) { return "Generated when an App Store app is installed on a device.", `This activity contains the following fields: - host_id: ID of the host on which the app was installed. +- self_service: App installation was initiated by device owner. - host_display_name: Display name of the host. - software_title: Name of the App Store app. - app_store_id: ID of the app on the Apple App Store. - command_uuid: UUID of the MDM command used to install the app.`, `{ "host_id": 42, + "self_service": true, "host_display_name": "Anna's MacBook Pro", "software_title": "Logic Pro", "app_store_id": "1234567", diff --git a/server/fleet/app.go b/server/fleet/app.go index b9227dd8bb..603ba2e0ab 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -120,9 +120,31 @@ type VulnerabilitySettings struct { DatabasesPath string `json:"databases_path"` } +// MDMAppleABMAssignmentInfo represents an user definition of the association +// between an ABM token (via organization name) and the teams used to associate +// hosts when they're ingested during the ABM sync. +type MDMAppleABMAssignmentInfo struct { + OrganizationName string `json:"organization_name"` + MacOSTeam string `json:"macos_team"` + IOSTeam string `json:"ios_team"` + IpadOSTeam string `json:"ipados_team"` +} + +// MDMAppleVolumePurchasingProgramInfo represents an user definition of the association +// between a VPP token (via location) and the team associations. +type MDMAppleVolumePurchasingProgramInfo struct { + Location string `json:"location"` + Teams []string `json:"teams"` +} + // MDM is part of AppConfig and defines the mdm settings. type MDM struct { - AppleBMDefaultTeam string `json:"apple_bm_default_team"` + // Deprecated: use AppleBussinessManager instead + DeprecatedAppleBMDefaultTeam string `json:"apple_bm_default_team,omitempty"` + + // AppleBusinessManager defines the associations between ABM tokens + // and the teams used to assign hosts when they're ingested from ABM. + AppleBusinessManager optjson.Slice[MDMAppleABMAssignmentInfo] `json:"apple_business_manager"` // AppleBMEnabledAndConfigured is set to true if Fleet has been // configured with the required Apple BM key pair or token. It can't be set @@ -136,6 +158,10 @@ type MDM struct { // PATCH /config API, it is only set automatically, internally, by detecting // the 403 Forbidden error with body T_C_NOT_SIGNED returned by the Apple BM // API. + // + // It is set to true as soon as one of the ABM tokens receives this error + // code, and is set to false only once all ABM tokens have agreed to the new + // terms. AppleBMTermsExpired bool `json:"apple_bm_terms_expired"` // EnabledAndConfigured is set to true if Fleet has been @@ -172,6 +198,8 @@ type MDM struct { WindowsSettings WindowsSettings `json:"windows_settings"` + VolumePurchasingProgram optjson.Slice[MDMAppleVolumePurchasingProgramInfo] `json:"volume_purchasing_program"` + ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into // account in the AppConfig Clone implementation! @@ -607,6 +635,25 @@ func (c *AppConfig) Copy() *AppConfig { clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) } + if c.MDM.AppleBusinessManager.Set { + abm := make([]MDMAppleABMAssignmentInfo, len(c.MDM.AppleBusinessManager.Value)) + for i, s := range c.MDM.AppleBusinessManager.Value { + abm[i] = s + } + clone.MDM.AppleBusinessManager = optjson.SetSlice(abm) + + } + + if c.MDM.VolumePurchasingProgram.Set { + vpp := make([]MDMAppleVolumePurchasingProgramInfo, len(c.MDM.VolumePurchasingProgram.Value)) + for i, s := range c.MDM.VolumePurchasingProgram.Value { + vpp[i].Location = s.Location + vpp[i].Teams = make([]string, len(s.Teams)) + copy(vpp[i].Teams, s.Teams) + } + clone.MDM.VolumePurchasingProgram = optjson.SetSlice(vpp) + } + return &clone } @@ -831,7 +878,6 @@ func (c AppConfig) MarshalJSON() ([]byte, error) { if !c.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { c.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) } - type aliasConfig AppConfig aa := aliasConfig(c) return json.Marshal(aa) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index e1cabf2533..832ee5d237 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -1,11 +1,13 @@ package fleet import ( + "bytes" "context" "crypto/md5" // nolint: gosec "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -203,6 +205,13 @@ type MDMAppleConfigProfile struct { UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change } +// MDMProfilesUpdates flags updates that were done during batch processing of profiles. +type MDMProfilesUpdates struct { + AppleConfigProfile bool + WindowsConfigProfile bool + AppleDeclaration bool +} + // ConfigurationProfileLabel represents the many-to-many relationship between // profiles and labels. // @@ -302,10 +311,24 @@ type MDMAppleProfilePayload struct { CommandUUID string `db:"command_uuid"` } -// FailedToInstallOnHost indicates whether this profile failed to be installed on the host (and +// DidNotInstallOnHost indicates whether this profile was not installed on the host (and // therefore is not, as far as Fleet knows, currently on the host). -func (p *MDMAppleProfilePayload) FailedToInstallOnHost() bool { - return p.Status != nil && *p.Status == MDMDeliveryFailed && p.OperationType == MDMOperationTypeInstall +func (p *MDMAppleProfilePayload) DidNotInstallOnHost() bool { + return p.Status != nil && (*p.Status == MDMDeliveryFailed || *p.Status == MDMDeliveryPending) && p.OperationType == MDMOperationTypeInstall +} + +func (p MDMAppleProfilePayload) Equal(other MDMAppleProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return p.ProfileUUID == other.ProfileUUID && + p.ProfileIdentifier == other.ProfileIdentifier && + p.ProfileName == other.ProfileName && + p.HostUUID == other.HostUUID && + p.HostPlatform == other.HostPlatform && + bytes.Equal(p.Checksum, other.Checksum) && + statusEqual && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID } type MDMAppleBulkUpsertHostProfilePayload struct { @@ -434,6 +457,8 @@ type HostDEPAssignment struct { // DeletedAt is the timestamp when Fleet was notified that device was deleted from the Fleet // MDM server in Apple Busines Manager (ABM). DeletedAt *time.Time `db:"deleted_at"` + // ABMTokenID is the ID of the ABM token that was used to make this DEP assignment. + ABMTokenID *uint `db:"abm_token_id"` } func (h *HostDEPAssignment) IsDEPAssignedToFleet() bool { @@ -496,12 +521,11 @@ type MDMAppleCommand struct { // MDMAppleSetupAssistant represents the setup assistant set for a given team // or no team. type MDMAppleSetupAssistant struct { - ID uint `json:"-" db:"id"` - TeamID *uint `json:"team_id" db:"team_id"` - Name string `json:"name" db:"name"` - Profile json.RawMessage `json:"enrollment_profile" db:"profile"` - ProfileUUID string `json:"-" db:"profile_uuid"` - UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` + ID uint `json:"-" db:"id"` + TeamID *uint `json:"team_id" db:"team_id"` + Name string `json:"name" db:"name"` + Profile json.RawMessage `json:"enrollment_profile" db:"profile"` + UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` } // AuthzType implements authz.AuthzTyper. @@ -658,6 +682,18 @@ type MDMAppleHostDeclaration struct { Checksum string `db:"checksum" json:"-"` } +func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.HostUUID == other.HostUUID && + p.DeclarationUUID == other.DeclarationUUID && + p.Name == other.Name && + p.Identifier == other.Identifier && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.Checksum == other.Checksum +} + func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration { var decl MDMAppleDeclaration @@ -841,3 +877,67 @@ type MDMAppleDDMActivation struct { ServerToken string `json:"ServerToken"` Type string `json:"Type"` // "com.apple.activation.simple" } + +// MDMBootstrapPackageStore is the interface to store and retrieve bootstrap +// package files. Fleet supports storing to the database and to an S3 bucket. +type MDMBootstrapPackageStore interface { + Get(ctx context.Context, packageID string) (io.ReadCloser, int64, error) + Put(ctx context.Context, packageID string, content io.ReadSeeker) error + Exists(ctx context.Context, packageID string) (bool, error) + Cleanup(ctx context.Context, usedPackageIDs []string, removeCreatedBefore time.Time) (int, error) +} + +// MDMAppleMachineInfo is a [device's information][1] sent as part of an MDM enrollment profile request +// +// [1]: https://developer.apple.com/documentation/devicemanagement/machineinfo +type MDMAppleMachineInfo struct { + IMEI string `plist:"IMEI,omitempty"` + Language string `plist:"LANGUAGE,omitempty"` + MDMCanRequestSoftwareUpdate bool `plist:"MDM_CAN_REQUEST_SOFTWARE_UPDATE"` + MEID string `plist:"MEID,omitempty"` + OSVersion string `plist:"OS_VERSION"` + PairingToken string `plist:"PAIRING_TOKEN,omitempty"` + Product string `plist:"PRODUCT"` + Serial string `plist:"SERIAL"` + SoftwareUpdateDeviceID string `plist:"SOFTWARE_UPDATE_DEVICE_ID,omitempty"` + SupplementalBuildVersion string `plist:"SUPPLEMENTAL_BUILD_VERSION,omitempty"` + SupplementalOSVersionExtra string `plist:"SUPPLEMENTAL_OS_VERSION_EXTRA,omitempty"` + UDID string `plist:"UDID"` + Version string `plist:"VERSION"` +} + +// MDMAppleSoftwareUpdateRequiredCode is the [code][1] specified by Apple to indicate that the device +// needs to perform a software update before enrollment and setup can proceed. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired +const MDMAppleSoftwareUpdateRequiredCode = "com.apple.softwareupdate.required" + +// MDMAppleSoftwareUpdateRequiredDetails is the [details][1] specified by Apple for the +// required software update. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired/details +type MDMAppleSoftwareUpdateRequiredDetails struct { + OSVersion string `json:"OSVersion"` + BuildVersion string `json:"BuildVersion"` +} + +// MDMAppleSoftwareUpdateRequired is the [error response][1] specified by Apple to indicate that the device +// needs to perform a software update before enrollment and setup can proceed. +// +// [1]: https://developer.apple.com/documentation/devicemanagement/errorcodesoftwareupdaterequired +type MDMAppleSoftwareUpdateRequired struct { + Code string `json:"code"` // "com.apple.softwareupdate.required" + Details MDMAppleSoftwareUpdateRequiredDetails `json:"details"` +} + +func NewMDMAppleSoftwareUpdateRequired(asset MDMAppleSoftwareUpdateAsset) *MDMAppleSoftwareUpdateRequired { + return &MDMAppleSoftwareUpdateRequired{ + Code: MDMAppleSoftwareUpdateRequiredCode, + Details: MDMAppleSoftwareUpdateRequiredDetails{OSVersion: asset.ProductVersion, BuildVersion: asset.Build}, + } +} + +type MDMAppleSoftwareUpdateAsset struct { + ProductVersion string `json:"ProductVersion"` + Build string `json:"Build"` +} diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index 16799f6cc3..240c37c9d7 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -7,18 +7,20 @@ import ( "crypto/x509" "encoding/json" "fmt" + "reflect" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" - "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" - "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/smallstep/pkcs7" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMDMAppleConfigProfile(t *testing.T) { @@ -416,3 +418,199 @@ func TestMDMProfileIsWithinGracePeriod(t *testing.T) { }) } } + +func TestMDMAppleHostDeclarationEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleHostDeclaration is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleHostDeclaration{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].DeclarationUUID = items[0].DeclarationUUID + fieldsInEqualMethod++ + items[1].Name = items[0].Name + fieldsInEqualMethod++ + items[1].Identifier = items[0].Identifier + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].Checksum = items[0].Checksum + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers to nil + items[0].Status = nil + items[1].Status = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestMDMAppleProfilePayloadEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleProfilePayload is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleProfilePayload{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + case reflect.Slice: + switch field.Type().Elem().Kind() { + case reflect.Uint8: + valueToSet := []byte("test") + field.Set(reflect.ValueOf(valueToSet)) + default: + t.Fatalf("unhandled slice type %s", field.Type().Elem().Kind()) + } + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + checksum0 := []byte("checksum") + checksum1 := []byte("checksum") + items[0].Checksum = checksum0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].ProfileIdentifier = items[0].ProfileIdentifier + fieldsInEqualMethod++ + items[1].ProfileName = items[0].ProfileName + fieldsInEqualMethod++ + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].HostPlatform = items[0].HostPlatform + fieldsInEqualMethod++ + items[1].Checksum = checksum1 + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].CommandUUID = items[0].CommandUUID + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleProfilePayload.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers and slices to nil + items[0].Status = nil + items[1].Status = nil + items[0].Checksum = nil + items[1].Checksum = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestConfigurationProfileLabelEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the cmp.Equal method on ConfigurationProfileLabel is updated when new fields are added. + // The cmp.Equal method is used to identify whether database update is needed. + + items := [...]ConfigurationProfileLabel{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Uint: + field.SetUint(uint64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + assert.False(t, cmp.Equal(items[0], items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].LabelName = items[0].LabelName + fieldsInEqualMethod++ + items[1].LabelID = items[0].LabelID + fieldsInEqualMethod++ + items[1].Broken = items[0].Broken + fieldsInEqualMethod++ + items[1].Exclude = items[0].Exclude + fieldsInEqualMethod++ + + assert.Equal(t, fieldsInEqualMethod, numberOfFields, + "Does cmp.Equal for ConfigurationProfileLabel needs to be updated for new/updated field(s)?") + assert.True(t, cmp.Equal(items[0], items[1])) + +} diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go index 72c3e9e0ba..b5edacc676 100644 --- a/server/fleet/calendar.go +++ b/server/fleet/calendar.go @@ -42,8 +42,8 @@ type UserCalendar interface { GetAndUpdateEvent(event *CalendarEvent, genBodyFn CalendarGenBodyFn, opts CalendarGetAndUpdateEventOpts) (updatedEvent *CalendarEvent, updated bool, err error) - // UpdateEventBody updates the body of the calendar event. - UpdateEventBody(event *CalendarEvent, genBodyFn CalendarGenBodyFn) error + // UpdateEventBody updates the body of the calendar event and returns new ETag + UpdateEventBody(event *CalendarEvent, genBodyFn CalendarGenBodyFn) (string, error) // DeleteEvent deletes the event with the given ID. DeleteEvent(event *CalendarEvent) error // StopEventChannel stops the event's callback channel. @@ -54,15 +54,17 @@ type UserCalendar interface { // Lock interface for managing distributed locks. type Lock interface { - // AcquireLock attempts to acquire a lock with the given key. value is the value to set for the key, which is used to release the lock. + // SetIfNotExist attempts to set an item with the given key. value is the value to set for the key, which is used to release the lock. // expireMs is the time in milliseconds after which the lock is automatically released. expireMs=0 means a default expiration time is used. // Returns true if the lock was acquired, false otherwise. - AcquireLock(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) + SetIfNotExist(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) // ReleaseLock attempts to release a lock with the given key and value. If key does not exist or value does not match, the lock is not released. // Returns true if the lock was released, false otherwise. ReleaseLock(ctx context.Context, key string, value string) (ok bool, err error) // Get retrieves the value of the given key. If the key does not exist, nil is returned. Get(ctx context.Context, key string) (*string, error) + // GetAndDelete retrieves the value of the given key and deletes the key. If the key does not exist, nil is returned. + GetAndDelete(ctx context.Context, key string) (*string, error) // AddToSet adds the value to the set identified by the given key. AddToSet(ctx context.Context, key string, value string) error // RemoveFromSet removes the value from the set identified by the given key. diff --git a/server/fleet/calendar_events.go b/server/fleet/calendar_events.go index 44d11306fc..5e730e283b 100644 --- a/server/fleet/calendar_events.go +++ b/server/fleet/calendar_events.go @@ -2,6 +2,7 @@ package fleet import ( "encoding/json" + "errors" "fmt" "time" ) @@ -30,7 +31,10 @@ func (ce *CalendarEvent) GetBodyTag() string { return d.BodyTag } -func (ce *CalendarEvent) SaveBodyTag(bodyTag string) error { +func (ce *CalendarEvent) SaveDataItems(keysAndValues ...string) error { + if len(keysAndValues)%2 != 0 { + return errors.New("SaveDataItem requires an even number of arguments") + } var result map[string]any if len(ce.Data) > 0 { err := json.Unmarshal(ce.Data, &result) @@ -40,7 +44,11 @@ func (ce *CalendarEvent) SaveBodyTag(bodyTag string) error { } else { result = make(map[string]any, 1) } - result["body_tag"] = bodyTag + for i := 0; i < len(keysAndValues); i += 2 { + key := keysAndValues[i] + value := keysAndValues[i+1] + result[key] = value + } data, err := json.Marshal(result) if err != nil { return fmt.Errorf("could not marshal event data: %w", err) diff --git a/server/fleet/calendar_events_test.go b/server/fleet/calendar_events_test.go index 3e79bd6bb4..931cd3889f 100644 --- a/server/fleet/calendar_events_test.go +++ b/server/fleet/calendar_events_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestBodyTag(t *testing.T) { +func TestSaveDataItems(t *testing.T) { t.Parallel() var event CalendarEvent @@ -16,7 +16,7 @@ func TestBodyTag(t *testing.T) { assert.Equal(t, "", event.GetBodyTag()) bodyTag := "bodyTag" - require.NoError(t, event.SaveBodyTag(bodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", bodyTag)) assert.Equal(t, bodyTag, event.GetBodyTag()) testMap := make(map[string]any, 5) @@ -29,11 +29,11 @@ func TestBodyTag(t *testing.T) { event.Data = data assert.Equal(t, oldBodyTag, event.GetBodyTag()) - require.NoError(t, event.SaveBodyTag(bodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", bodyTag)) assert.Equal(t, bodyTag, event.GetBodyTag()) // Make sure data was not modified - require.NoError(t, event.SaveBodyTag(oldBodyTag)) + require.NoError(t, event.SaveDataItems("body_tag", oldBodyTag)) var result map[string]any require.NoError(t, json.Unmarshal(event.Data, &result)) assert.Equal(t, testMap, result) diff --git a/server/fleet/capabilities.go b/server/fleet/capabilities.go index be397bcc32..2e12810be8 100644 --- a/server/fleet/capabilities.go +++ b/server/fleet/capabilities.go @@ -99,5 +99,11 @@ func GetServerDeviceCapabilities() CapabilityMap { return capabilities } +func GetOrbitClientCapabilities() CapabilityMap { + return CapabilityMap{ + CapabilityEscrowBuddy: {}, + } +} + // CapabilitiesHeader is the header name used to communicate the capabilities. const CapabilitiesHeader = "X-Fleet-Capabilities" diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index 250a8dc3b5..937fb85a51 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -24,6 +24,7 @@ const ( CronAppleMDMIPhoneIPadRefetcher CronScheduleName = "apple_mdm_iphone_ipad_refetcher" CronAppleMDMAPNsPusher CronScheduleName = "apple_mdm_apns_pusher" CronCalendar CronScheduleName = "calendar" + CronUninstallSoftwareMigration CronScheduleName = "uninstall_software_migration" ) type CronSchedulesService interface { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 6847f398af..99b2cdb7d2 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -336,7 +336,15 @@ type Datastore interface { // ListIOSAndIPadOSToRefetch returns the UUIDs of iPhones/iPads that should be refetched (their details haven't been // updated in the given `interval`). - ListIOSAndIPadOSToRefetch(ctx context.Context, refetchInterval time.Duration) (uuids []string, err error) + ListIOSAndIPadOSToRefetch(ctx context.Context, refetchInterval time.Duration) (devices []AppleDevicesToRefetch, err error) + // AddHostMDMCommands adds the provided MDM commands to the host to track which commands have been sent. + AddHostMDMCommands(ctx context.Context, commands []HostMDMCommand) error + // GetHostMDMCommands returns the MDM commands that have been sent to the host. + GetHostMDMCommands(ctx context.Context, hostID uint) (commands []HostMDMCommand, err error) + // RemoveHostMDMCommand removes the provided MDM command from the host, indicating that it has been processed. + RemoveHostMDMCommand(ctx context.Context, command HostMDMCommand) error + // CleanupHostMDMCommands removes invalid and stale MDM commands sent to hosts. + CleanupHostMDMCommands(ctx context.Context) error // IsHostConnectedToFleetMDM verifies if the host has an active Fleet MDM enrollment with this server IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error) @@ -523,7 +531,12 @@ type Datastore interface { // InsertSoftwareInstallRequest tracks a new request to install the provided // software installer in the host. It returns the auto-generated installation // uuid. - InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) + InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) + // InsertSoftwareUninstallRequest tracks a new request to uninstall the provided + // software installer on the host. executionID is the script execution ID corresponding to uninstall script + InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error + // GetSoftwareTitleNameFromExecutionID returns the software title name associated with the provided software install execution ID. + GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error) /////////////////////////////////////////////////////////////////////////////// // SoftwareStore @@ -671,6 +684,8 @@ type Datastore interface { // and have a calendar event scheduled. GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]HostPolicyMembershipData, error) + // GetPoliciesWithAssociatedInstaller returns team policies that have an associated installer. + GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicySoftwareInstallerData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // Methods used for async processing of host policy query results. @@ -865,6 +880,8 @@ type Datastore interface { SetOrUpdateMunkiInfo(ctx context.Context, hostID uint, version string, errors, warnings []string) error SetOrUpdateMDMData(ctx context.Context, hostID uint, isServer, enrolled bool, serverURL string, installedFromDep bool, name string, fleetEnrollRef string) error + // UpdateMDMData updates the `enrolled` field of the host with the given ID. + UpdateMDMData(ctx context.Context, hostID uint, enrolled bool) error // SetOrUpdateHostEmailsFromMdmIdpAccounts sets or updates the host emails associated with the provided // host based on the MDM IdP account information associated with the provided fleet enrollment reference. SetOrUpdateHostEmailsFromMdmIdpAccounts(ctx context.Context, hostID uint, fleetEnrollmentRef string) error @@ -989,6 +1006,8 @@ type Datastore interface { CountVulnerabilities(ctx context.Context, opt VulnListOptions) (uint, error) // UpdateVulnerabilityHostCounts updates hosts counts for all vulnerabilities. UpdateVulnerabilityHostCounts(ctx context.Context) error + // IsCVEKnownToFleet checks if the provided CVE is known to Fleet. + IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) /////////////////////////////////////////////////////////////////////////////// // Apple MDM @@ -1092,12 +1111,15 @@ type Datastore interface { // UpsertMDMAppleHostDEPAssignments ensures there's an entry in // `host_dep_assignments` for all the provided hosts. - UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []Host) error + UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []Host, abmTokenID uint) error // IngestMDMAppleDevicesFromDEPSync creates new Fleet host records for MDM-enrolled devices that are - // not already enrolled in Fleet. It returns the number of hosts created, the team id that they - // joined (nil for no team), and an error. - IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device) (int64, *uint, error) + // not already enrolled in Fleet. It returns the number of hosts created, and an error. + IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam, iosTeam, ipadTeam *Team) (int64, error) + + // IngestMDMAppleDeviceFromOTAEnrollment creates new host records for + // MDM-enrolled devices via OTA that are not already enrolled in Fleet. + IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo MDMAppleMachineInfo) error // MDMAppleUpsertHost creates or matches a Fleet host record for an // MDM-enrolled device. @@ -1150,7 +1172,9 @@ type Datastore interface { // remove for each affected host to pending for the provided criteria, which // may be either a list of hostIDs, teamIDs, profileUUIDs or hostUUIDs (only // one of those ID types can be provided). - BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error + BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, + profileUUIDs, hostUUIDs []string) (updates MDMProfilesUpdates, + err error) // GetMDMAppleProfilesContents retrieves the XML contents of the // profiles requested. @@ -1183,25 +1207,32 @@ type Datastore interface { // to any team). GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*MDMAppleFileVaultSummary, error) - // InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the database - InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage) error + // InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the + // database (or S3 if configured). + InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error // CopyMDMAppleBootstrapPackage copies the bootstrap package specified in the app config (if any) // specified team (and a new token is assigned). It also updates the team config with the default bootstrap package URL. CopyDefaultMDMAppleBootstrapPackage(ctx context.Context, ac *AppConfig, toTeamID uint) error - // DeleteMDMAppleBootstrapPackage deletes the bootstrap package for the given team id + // DeleteMDMAppleBootstrapPackage deletes the bootstrap package for the given team id. DeleteMDMAppleBootstrapPackage(ctx context.Context, teamID uint) error - // GetMDMAppleBootstrapPackageMeta returns metadata about the bootstrap package for a team + // GetMDMAppleBootstrapPackageMeta returns metadata about the bootstrap + // package for a team. GetMDMAppleBootstrapPackageMeta(ctx context.Context, teamID uint) (*MDMAppleBootstrapPackage, error) - // GetMDMAppleBootstrapPackageBytes returns the bytes of a bootstrap package with the given token - GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*MDMAppleBootstrapPackage, error) - // GetMDMAppleBootstrapPackageSummary returns an aggregated summary of - // the status of the bootstrap package for hosts in a team. + // GetMDMAppleBootstrapPackageBytes returns the bytes of a bootstrap package + // with the given token. + GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string, pkgStore MDMBootstrapPackageStore) (*MDMAppleBootstrapPackage, error) + // GetMDMAppleBootstrapPackageSummary returns an aggregated summary of the + // status of the bootstrap package for hosts in a team. GetMDMAppleBootstrapPackageSummary(ctx context.Context, teamID uint) (*MDMAppleBootstrapPackageSummary, error) // RecordHostBootstrapPackage records a command used to install a // bootstrap package in a host. RecordHostBootstrapPackage(ctx context.Context, commandUUID string, hostUUID string) error + // CleanupUnusedBootstrapPackages will remove bootstrap packages that have no + // references to them from the mdm_apple_bootstrap_packages table. + CleanupUnusedBootstrapPackages(ctx context.Context, pkgStore MDMBootstrapPackageStore, removeCreatedBefore time.Time) error + // GetHostMDMMacOSSetup returns the MDM macOS setup information for the specified host id. GetHostMDMMacOSSetup(ctx context.Context, hostID uint) (*HostMDMMacOSSetup, error) @@ -1221,11 +1252,18 @@ type Datastore interface { SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst *MDMAppleSetupAssistant) (*MDMAppleSetupAssistant, error) // Get the MDM Apple Setup Assistant for the provided team or no team. GetMDMAppleSetupAssistant(ctx context.Context, teamID *uint) (*MDMAppleSetupAssistant, error) + // Get the MDM Apple Setup Assistant profile uuid and timestamp for the + // specified ABM token identified by organization name. + GetMDMAppleSetupAssistantProfileForABMToken(ctx context.Context, teamID *uint, abmTokenOrgName string) (string, time.Time, error) // Delete the MDM Apple Setup Assistant for the provided team or no team. DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *uint) error // Set the profile UUID generated by the call to Apple's DefineProfile API of // the setup assistant for a team or no team. - SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error + // + // With multi-ABM token support, this profileUUID is stored along with the + // ABM token used to register it. The token is identified by its organization + // name. + SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID, abmTokenOrgName string) error // Set the profile UUID generated by the call to Apple's DefineProfile API // of the default setup assistant for a team or no team. The default @@ -1233,11 +1271,16 @@ type Datastore interface { // the end-user authentication which may be configured per-team and affects // the JSON registered with Apple's API, possibly resulting in different // profile UUIDs for the same profile depending on the team. - SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error + // + // With multi-ABM token support, this profileUUID is stored along with the + // ABM token used to register it. The token is identified by its organization + // name. + SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID, abmTokenOrgName string) error // Get the profile UUID and last update timestamp for the default setup - // assistant for a team or no team. - GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint) (profileUUID string, updatedAt time.Time, err error) + // assistant for a team or no team, as registered with the ABM token + // represented by the organization name. + GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint, abmTokenOrgName string) (profileUUID string, updatedAt time.Time, err error) // GetMatchingHostSerials receives a list of serial numbers and returns // a map that only contains the serials that have a matching row in the `hosts` table. @@ -1254,7 +1297,7 @@ type Datastore interface { // 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) + ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]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) @@ -1286,6 +1329,10 @@ type Datastore interface { // the provided value. MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUUID string, status *MDMDeliveryStatus, detail string) error + // GetMDMAppleOSUpdatesSettingsByHostSerial returns applicable Apple OS update settings (if any) + // for the host with the given serial number. The host must be DEP assigned to Fleet. + GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*AppleOSUpdateSettings, error) + // InsertMDMConfigAssets inserts MDM related config assets, such as SCEP and APNS certs and keys. InsertMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error @@ -1309,6 +1356,54 @@ type Datastore interface { // generated ones. ReplaceMDMConfigAssets(ctx context.Context, assets []MDMConfigAsset) error + // GetABMTokenByOrgName retrieves the Apple Business Manager token identified by + // its unique name (the organization name). + GetABMTokenByOrgName(ctx context.Context, orgName string) (*ABMToken, error) + + // SaveABMToken updates the ABM token using the provided struct. + SaveABMToken(ctx context.Context, tok *ABMToken) error + + InsertVPPToken(ctx context.Context, tok *VPPTokenData) (*VPPTokenDB, error) + ListVPPTokens(ctx context.Context) ([]*VPPTokenDB, error) + GetVPPToken(ctx context.Context, tokenID uint) (*VPPTokenDB, error) + GetVPPTokenByTeamID(ctx context.Context, teamID *uint) (*VPPTokenDB, error) + // UpdateVPPTokenTeams sets the teams associated with this token. + // Note that updating the token's associations removes all + // apps-team associations using this token + UpdateVPPTokenTeams(ctx context.Context, id uint, teams []uint) (*VPPTokenDB, error) + UpdateVPPToken(ctx context.Context, id uint, tok *VPPTokenData) (*VPPTokenDB, error) + DeleteVPPToken(ctx context.Context, tokenID uint) error + + // SetABMTokenTermsExpiredForOrgName is a specialized method to set only the + // terms_expired flag of the ABM token identified by the organization name. + // It returns whether that flag was previously set for this token. + SetABMTokenTermsExpiredForOrgName(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) + + // CountABMTokensWithTermsExpired returns a count of ABM tokens that are + // flagged with the Apple BM terms expired. + CountABMTokensWithTermsExpired(ctx context.Context) (int, error) + + // InsertABMToken inserts a new ABM token into the datastore. + InsertABMToken(ctx context.Context, tok *ABMToken) (*ABMToken, error) + + // ListABMTokens lists all of the ABM tokens. + ListABMTokens(ctx context.Context) ([]*ABMToken, error) + + // DeleteABMToken deletes the given ABM token from the datastore. + DeleteABMToken(ctx context.Context, tokenID uint) error + + // GetABMTokenByID retrieves the ABM token with the given ID. + GetABMTokenByID(ctx context.Context, tokenID uint) (*ABMToken, error) + + // GetABMTokenCount returns the number of ABM tokens in the DB. + GetABMTokenCount(ctx context.Context) (int, error) + + // GetABMTokenOrgNamesAssociatedWithTeam returns the set of ABM organization + // names that correspond to the union of + // - the tokens used to create each of the DEP hosts in that team. + // - the tokens targeting that team as default for any platform. + GetABMTokenOrgNamesAssociatedWithTeam(ctx context.Context, teamID *uint) ([]string, error) + /////////////////////////////////////////////////////////////////////////////// // Microsoft MDM @@ -1434,7 +1529,8 @@ type Datastore interface { // BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or // no team in a single transaction. - BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, macDeclarations []*MDMAppleDeclaration) error + BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, + macDeclarations []*MDMAppleDeclaration) (updates MDMProfilesUpdates, err error) // NewMDMAppleDeclaration creates and returns a new MDM Apple declaration. NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error) @@ -1451,8 +1547,8 @@ type Datastore interface { // SetHostScriptExecutionResult stores the result of a host script execution // and returns the updated host script result record. Note that it does not // fail if the script execution request does not exist, in this case it will - // return nil, nil. - SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (*HostScriptResult, error) + // return nil, "", nil. action is populated if this script was an MDM action (lock/unlock/wipe/uninstall). + SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (hsr *HostScriptResult, action string, err error) // GetHostScriptExecutionResult returns the result of a host script // execution. It returns the host script results even if no results have been // received, it is the caller's responsibility to check if that was the case @@ -1472,6 +1568,10 @@ type Datastore interface { // script. GetScriptContents(ctx context.Context, id uint) ([]byte, error) + // GetAnyScriptContents returns the raw script contents of the corresponding + // script, regardless whether it is present in the scripts table. + GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error) + // DeleteScript deletes the script identified by its id. DeleteScript(ctx context.Context, id uint) error @@ -1542,19 +1642,49 @@ type Datastore interface { // installer execution IDs that have not yet been run for a given host ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) + // GetHostLastInstallData returns the data for the last installation of a package on a host. + GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*HostLastInstallData, error) + // MatchOrCreateSoftwareInstaller matches or creates a new software installer. MatchOrCreateSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (uint, error) // GetSoftwareInstallerMetadataByID returns the software installer corresponding to the installer id. GetSoftwareInstallerMetadataByID(ctx context.Context, id uint) (*SoftwareInstaller, error) + // ValidateSoftwareInstallerAccess checks if a host has access to + // an installer. Access is granted if there is currently an unfinished + // install request present in host_software_installs + ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) + // GetSoftwareInstallerMetadataByTeamAndTitleID returns the software // installer corresponding to the specified team and title ids. If // withScriptContents is true, also returns the contents of the install and // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) - GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*VPPApp, error) + // GetSoftwareInstallersWithoutPackageIDs returns a map of software installers to storage ids that do not have a package ID. + GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) + + // UpdateSoftwareInstallerWithoutPackageIDs updates the software installer corresponding to the id. Used to add uninstall scripts. + UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload UploadSoftwareInstallerPayload) error + + // ProcessInstallerUpdateSideEffects handles, in a transaction, the following based on whether metadata + // or package are dirty: + // 1. If metadata or package were updated, removes host_software_installer and queued script records for + // pending non-VPP installs and uninstalls for an installer by its ID. See implementation for caveats. + // 2. If package was updated, marks host software installer rows for the supplied installer + // as removed, hiding them from stats calculations (note that this will null out installer statuses due + // to how the virtual column works). + ProcessInstallerUpdateSideEffects(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error + + // SaveInstallerUpdates persists new values to an existing installer. See comments in the payload struct + // for which fields must be set. + SaveInstallerUpdates(ctx context.Context, payload *UpdateSoftwareInstallerPayload) error + + // UpdateInstallerSelfServiceFlag sets an installer's self-service flag without modifying anything else + UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error + + GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*VPPApp, error) // GetVPPAppMetadataByTeamAndTitleID returns the VPP app corresponding to the // specified team and title ids. GetVPPAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*VPPAppStoreApp, error) @@ -1578,21 +1708,24 @@ type Datastore interface { // CleanupUnusedSoftwareInstallers will remove software installers that have // no references to them from the software_installers table. - CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore SoftwareInstallerStore) error + CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore SoftwareInstallerStore, removeCreatedBefore time.Time) error // BatchSetSoftwareInstallers sets the software installers for the given team or no team. BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*UploadSoftwareInstallerPayload) error + GetSoftwareInstallers(ctx context.Context, tmID uint) ([]SoftwarePackageResponse, error) // HasSelfServiceSoftwareInstallers returns true if self-service software installers are available for the team or globally. HasSelfServiceSoftwareInstallers(ctx context.Context, platform string, teamID *uint) (bool, error) BatchInsertVPPApps(ctx context.Context, apps []*VPPApp) error - GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[VPPAppID]struct{}, error) - SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []VPPAppID) error + GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[VPPAppID]VPPAppTeam, error) + SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []VPPAppTeam) error InsertVPPAppWithTeam(ctx context.Context, app *VPPApp, teamID *uint) (*VPPApp, error) - InsertHostVPPSoftwareInstall(ctx context.Context, hostID, userID uint, appID VPPAppID, commandUUID, associatedEventID string) error + InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, appID VPPAppID, commandUUID, associatedEventID string, selfService bool) error GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error) + + GetVPPTokenByLocation(ctx context.Context, loc string) (*VPPTokenDB, error) } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with @@ -1606,6 +1739,7 @@ type MDMAppleStore interface { type MDMAssetRetriever interface { GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []MDMAssetName) (map[MDMAssetName]MDMConfigAsset, error) + GetABMTokenByOrgName(ctx context.Context, orgName string) (*ABMToken, error) } // Cloner represents any type that can clone itself. Used for the cached_mysql diff --git a/server/fleet/errors.go b/server/fleet/errors.go index aee72fd458..2d3b53260b 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -20,9 +20,10 @@ var ( ErrMissingLicense = &licenseError{} ErrMDMNotConfigured = &MDMNotConfiguredError{} - MDMNotConfiguredMessage = "MDM features aren't turned on in Fleet. For more information about setting up MDM, please visit https://fleetdm.com/docs/using-fleet" - WindowsMDMNotConfiguredMessage = "Windows MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM." - AppleMDMNotConfiguredMessage = "macOS MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM." + MDMNotConfiguredMessage = "MDM features aren't turned on in Fleet. For more information about setting up MDM, please visit https://fleetdm.com/docs/using-fleet" + WindowsMDMNotConfiguredMessage = "Windows MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM." + AppleMDMNotConfiguredMessage = "macOS MDM isn't turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM." + AppleABMDefaultTeamDeprecatedMessage = "mdm.apple_bm_default_team has been deprecated. Please use the new mdm.apple_business_manager key documented here: https://fleetdm.com/learn-more-about/apple-business-manager-gitops" ) // ErrWithStatusCode is an interface for errors that should set a specific HTTP @@ -278,6 +279,36 @@ func (e PermissionError) PermissionError() []map[string]string { return forbidden } +// OTAForbiddenError is a special kind of forbidden error that intentionally +// exposes information about the error so it can be shown in iPad/iPhone native +// dialogs during OTA enrollment. +// +// I couldn't find any documentation but the way it works is: +// +// - if the response has a status code 403 +// - and the body has a `message` field +// +// the content of `message` will be displayed to the end user. +type OTAForbiddenError struct { + ErrorWithUUID + InternalErr error +} + +func (e OTAForbiddenError) Error() string { + return "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin." +} + +func (e OTAForbiddenError) StatusCode() int { + return http.StatusForbidden +} + +func (e OTAForbiddenError) Internal() string { + if e.InternalErr == nil { + return "" + } + return e.InternalErr.Error() +} + // licenseError is returned when the application is not properly licensed. type licenseError struct { ErrorWithUUID diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index ac0e86723d..2f07240b16 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -7,6 +7,8 @@ import ( "fmt" "strings" "time" + + "github.com/Masterminds/semver" ) type HostStatus string @@ -1222,3 +1224,46 @@ func IsEligibleForDEPMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetM // the checkout message from the host. (!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet) } + +var macOSADEMigrationOnlyLastVersion = semver.MustParse("14") + +// IsEligibleForManualMigration returns true if the host is manually enrolled into a 3rd party MDM +// and is able to migrate to Fleet. +func IsEligibleForManualMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetMDM bool) (bool, error) { + goodVersion, err := IsMacOSMajorVersionOK(host) + if err != nil { + return false, fmt.Errorf("checking macOS version for manual migration eligibility: %w", err) + } + + return goodVersion && + host.IsOsqueryEnrolled() && + !host.IsDEPAssignedToFleet() && + mdmInfo != nil && + !mdmInfo.InstalledFromDep && + !mdmInfo.HasJSONProfileAssigned() && + mdmInfo.Enrolled && + (!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet), nil +} + +func IsMacOSMajorVersionOK(host *Host) (bool, error) { + if host == nil { + return false, nil + } + + parts := strings.Split(host.OSVersion, " ") + + if len(parts) < 2 || parts[0] != "macOS" { + return false, nil + } + + version, err := semver.NewVersion(parts[1]) + if err != nil { + return false, fmt.Errorf("parsing macOS version \"%s\": %w", parts[1], err) + } + + if version.GreaterThan(macOSADEMigrationOnlyLastVersion) { + return true, nil + } + + return false, nil +} diff --git a/server/fleet/hosts_test.go b/server/fleet/hosts_test.go index 94d0cd40a0..49c781e81f 100644 --- a/server/fleet/hosts_test.go +++ b/server/fleet/hosts_test.go @@ -222,6 +222,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse DEPAssignProfileResponseStatus enrolledInThirdPartyMDM bool expected bool + expectedManual bool + hostOS string }{ { name: "Eligible for DEP migration", @@ -230,6 +232,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: true, expected: true, + expectedManual: false, }, { name: "Not eligible - osqueryHostID nil", @@ -238,6 +241,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - not DEP assigned to Fleet", @@ -246,6 +250,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - not enrolled in third-party MDM", @@ -254,6 +259,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: false, expected: false, + expectedManual: false, }, { name: "Not eligible - not DEP assigned and DEP profile failed", @@ -262,6 +268,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseNotAccessible, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: true, + hostOS: "macOS 14.5", }, { name: "Not eligible - DEP assigned and DEP profile failed", @@ -270,6 +278,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseFailed, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - DEP assigned but not response yet", @@ -278,6 +287,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: "", enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - DEP assigned but not accessible", @@ -286,6 +296,27 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseNotAccessible, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, + }, + { + name: "Manual migration eligible - enrolled in 3rd party, but not DEP", + osqueryHostID: ptr.String("some-id"), + depAssignedToFleet: ptr.Bool(false), + depProfileResponse: "", + enrolledInThirdPartyMDM: true, + expected: false, + expectedManual: true, + hostOS: "macOS 14.5", + }, + { + name: "Manual migration ineligible - enrolled in 3rd party, not DEP, but OS version too low", + osqueryHostID: ptr.String("some-id"), + depAssignedToFleet: ptr.Bool(false), + depProfileResponse: "", + enrolledInThirdPartyMDM: true, + expected: false, + expectedManual: false, + hostOS: "macOS 13.9", }, } @@ -294,6 +325,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { host := &Host{ OsqueryHostID: tc.osqueryHostID, DEPAssignedToFleet: tc.depAssignedToFleet, + OSVersion: tc.hostOS, } mdmInfo := &HostMDM{ @@ -303,6 +335,9 @@ func TestIsEligibleForDEPMigration(t *testing.T) { } require.Equal(t, tc.expected, IsEligibleForDEPMigration(host, mdmInfo, false)) + manual, err := IsEligibleForManualMigration(host, mdmInfo, false) + require.NoError(t, err) + require.Equal(t, tc.expectedManual, manual) }) } } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index a1e51f8ca7..0e5cf32971 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -43,6 +43,42 @@ func (a AppleBM) AuthzType() string { return "mdm_apple" } +// TODO: during API implementation, remove AppleBM above or reconciliate those +// two types. We'll likely need a new authz type for the ABM token. +type ABMToken struct { + ID uint `db:"id" json:"id"` + AppleID string `db:"apple_id" json:"apple_id"` + OrganizationName string `db:"organization_name" json:"org_name"` + RenewAt time.Time `db:"renew_at" json:"renew_date"` + TermsExpired bool `db:"terms_expired" json:"terms_expired"` + MacOSDefaultTeamID *uint `db:"macos_default_team_id" json:"-"` + IOSDefaultTeamID *uint `db:"ios_default_team_id" json:"-"` + IPadOSDefaultTeamID *uint `db:"ipados_default_team_id" json:"-"` + EncryptedToken []byte `db:"token" json:"-"` + + // MDMServerURL is not a database field, it is computed from the AppConfig's + // Server URL and the static path to the MDM endpoint (using + // apple_mdm.ResolveAppleMDMURL). + MDMServerURL string `db:"-" json:"mdm_server_url"` + + // the following fields are not in the abm_tokens table, they must be queried + // by a LEFT JOIN on the corresponding team, coalesced to "No team" if + // null (no team). + MacOSTeamName string `db:"macos_team" json:"-"` + IOSTeamName string `db:"ios_team" json:"-"` + IPadOSTeamName string `db:"ipados_team" json:"-"` + + // These fields are composed of the ID and name fields above, and are used in API responses. + MacOSTeam ABMTokenTeam `json:"macos_team"` + IOSTeam ABMTokenTeam `json:"ios_team"` + IPadOSTeam ABMTokenTeam `json:"ipados_team"` +} + +type ABMTokenTeam struct { + Name string `json:"name"` + ID uint `json:"team_id"` +} + type AppleCSR struct { // NOTE: []byte automatically JSON-encodes as a base64-encoded string APNsKey []byte `json:"apns_key"` @@ -54,13 +90,15 @@ func (a AppleCSR) AuthzType() string { return "mdm_apple" } -// AppConfigUpdated is the minimal interface required to get and update the -// AppConfig, as required to handle the DEP API errors to flag that Apple's -// terms have changed and must be accepted. The Fleet Datastore satisfies -// this interface. -type AppConfigUpdater interface { +// ABMTermsUpdater is the minimal interface required to get and update the +// AppConfig, and set an ABM token's terms_expired flag as required to handle +// the DEP API errors to indicate that Apple's terms have changed and must be +// accepted. The Fleet Datastore satisfies this interface. +type ABMTermsUpdater interface { AppConfig(ctx context.Context) (*AppConfig, error) SaveAppConfig(ctx context.Context, info *AppConfig) error + SetABMTokenTermsExpiredForOrgName(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) + CountABMTokensWithTermsExpired(ctx context.Context) (int, error) } // MDMIdPAccount contains account information of a third-party IdP that can be @@ -625,14 +663,19 @@ const ( // MDMAssetABMCert is the name of the ABM (Apple Business Manager) // private key used to encrypt MDMAssetABMToken MDMAssetABMCert MDMAssetName = "abm_cert" - // MDMAssetABMToken is an encrypted JSON file that contains a token - // that can be used for the authentication process with the ABM API - MDMAssetABMToken MDMAssetName = "abm_token" + // MDMAssetABMTokenDeprecated is an encrypted JSON file that contains a token + // that can be used for the authentication process with the ABM API. + // Deprecated: ABM tokens are now stored in the abm_tokens table, they are + // not in mdm_config_assets anymore. + MDMAssetABMTokenDeprecated MDMAssetName = "abm_token" // MDMAssetSCEPChallenge defines the shared secret used to issue SCEP // certificatges to Apple devices. MDMAssetSCEPChallenge MDMAssetName = "scep_challenge" - // MDMAssetVPPToken is the name of the token used by MDM to authenticate to Apple's VPP service. - MDMAssetVPPToken MDMAssetName = "vpp_token" + // MDMAssetVPPTokenDeprecated is the name of the token used by MDM to + // authenticate to Apple's VPP service. + // Deprecated: VPP tokens are now stored in the vpp_tokens table, they are + // not in mdm_config_assets anymore. + MDMAssetVPPTokenDeprecated MDMAssetName = "vpp_token" ) type MDMConfigAsset struct { @@ -695,9 +738,10 @@ func FilterMacOSOnlyProfilesFromIOSIPadOS(profiles []*MDMAppleProfilePayload) [] return profiles[:i] } -// RefetchCommandUUIDPrefix is the prefix used for MDM commands used to refetch information from iOS/iPadOS devices. -const RefetchCommandUUIDPrefix = "REFETCH-" -const RefetchAppsCommandUUIDPrefix = "REFETCH-APPS-" +// RefetchBaseCommandUUIDPrefix and below command prefixes are the prefixes used for MDM commands used to refetch information from iOS/iPadOS devices. +const RefetchBaseCommandUUIDPrefix = "REFETCH-" +const RefetchDeviceCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "DEVICE-" +const RefetchAppsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "APPS-" // VPPTokenInfo is the representation of the VPP token that we send out via API. type VPPTokenInfo struct { @@ -724,6 +768,50 @@ type VPPTokenData struct { // structure of `VPPTokenRaw`. Token string `json:"token"` } + +const VPPTimeFormat = "2006-01-02T15:04:05Z0700" + +// VPPTokenDB represents a VPP token record in the DB +type VPPTokenDB struct { + ID uint `db:"id" json:"id"` + OrgName string `db:"organization_name" json:"org_name"` + Location string `db:"location" json:"location"` + RenewDate time.Time `db:"renew_at" json:"renew_date"` + // Token is the token dowloaded from ABM. It is the base64 encoded + // JSON object with the structure of `VPPTokenRaw` + Token string `db:"token" json:"-"` + Teams []TeamTuple `json:"teams"` + // CreatedAt time.Time `json:"created_at" db:"created_at"` + // UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type TeamTuple struct { + ID uint `json:"team_id"` + Name string `json:"name"` +} + +type NullTeamType string + +const ( + // VPP token is inactive, only valid option if teamID is set. + NullTeamNone NullTeamType = "none" + // VPP token is available for all teams. + NullTeamAllTeams NullTeamType = "allteams" + // VPP token is available only for "No team" team. + NullTeamNoTeam NullTeamType = "noteam" +) + +func (n NullTeamType) PrettyName() string { + switch n { + case NullTeamAllTeams: + return ReservedNameAllTeams + case NullTeamNoTeam: + return ReservedNameNoTeam + default: + return string(n) + } +} + type AppleDevice int const ( @@ -739,3 +827,42 @@ const ( IOSPlatform AppleDevicePlatform = "ios" IPadOSPlatform AppleDevicePlatform = "ipados" ) + +var VPPAppsPlatforms = []AppleDevicePlatform{IOSPlatform, IPadOSPlatform, MacOSPlatform} + +type AppleDevicesToRefetch struct { + HostID uint `db:"host_id"` + UUID string `db:"uuid"` + CommandsAlreadySent MDMCommandsAlreadySent `db:"commands_already_sent"` +} + +type MDMCommandsAlreadySent []string + +func (c *MDMCommandsAlreadySent) Scan(src interface{}) error { + if src == nil { + return nil + } + + raw, ok := src.([]byte) + if !ok { + return fmt.Errorf("unexpected type for MDMCommandsAlreadySent: %T", src) + } + // Filter out [null] command types which MySQL returns when there are no commands_already_sent. + // For details, see: https://dev.mysql.com/doc/refman/8.4/en/aggregate-functions.html#function_json-arrayagg + if string(raw) == "[null]" { + *c = nil + return nil + } + + var commands MDMCommandsAlreadySent + if err := json.Unmarshal(raw, &commands); err != nil { + return err + } + *c = commands + return nil +} + +type HostMDMCommand struct { + HostID uint `db:"host_id"` + CommandType string `db:"command_type"` +} diff --git a/server/fleet/mdm_test.go b/server/fleet/mdm_test.go index a1dd3d49d5..256b65be64 100644 --- a/server/fleet/mdm_test.go +++ b/server/fleet/mdm_test.go @@ -8,6 +8,7 @@ import ( "regexp" "testing" + ctxabm "github.com/fleetdm/fleet/v4/server/contexts/apple_bm" "github.com/fleetdm/fleet/v4/server/fleet" fleetmdm "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -20,8 +21,6 @@ import ( ) func TestDEPClient(t *testing.T) { - ctx := context.Background() - rxToken := regexp.MustCompile(`oauth_token="(\w+)"`) const ( validToken = "OK" @@ -79,49 +78,116 @@ func TestDEPClient(t *testing.T) { return nil } - checkDSCalled := func(readInvoked, writeInvoked bool) { + termsExpiredByOrgName := map[string]bool{ + "org1": false, + "org2": false, + } + ds.SetABMTokenTermsExpiredForOrgNameFunc = func(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) { + was, ok := termsExpiredByOrgName[orgName] + if !ok { + return expired, nil + } + termsExpiredByOrgName[orgName] = expired + return was, nil + } + ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { + count := 0 + for _, expired := range termsExpiredByOrgName { + if expired { + count++ + } + } + return count, nil + } + + checkDSCalled := func(readInvoked, writeTokInvoked, writeAppCfgInvoked bool) { require.Equal(t, readInvoked, ds.AppConfigFuncInvoked) - require.Equal(t, writeInvoked, ds.SaveAppConfigFuncInvoked) + require.Equal(t, readInvoked, ds.CountABMTokensWithTermsExpiredFuncInvoked) + require.Equal(t, writeTokInvoked, ds.SetABMTokenTermsExpiredForOrgNameFuncInvoked) + require.Equal(t, writeAppCfgInvoked, ds.SaveAppConfigFuncInvoked) ds.AppConfigFuncInvoked = false + ds.CountABMTokensWithTermsExpiredFuncInvoked = false ds.SaveAppConfigFuncInvoked = false + ds.SetABMTokenTermsExpiredForOrgNameFuncInvoked = false } cases := []struct { - token string - wantErr bool - readInvoked bool - writeInvoked bool - termsFlag bool + token string + orgName string + wantErr bool + readInvoked bool + writeTokInvoked bool + writeAppCfgInvoked bool + wantAppCfgTermsFlag bool + wantToksTermsFlags map[string]bool }{ // use a valid token, appconfig should not be updated (already unflagged) - {token: validToken, wantErr: false, readInvoked: true, writeInvoked: false, termsFlag: false}, + {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + + // use a valid token without org, nothing is checked + {token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, + + // use an invalid token without org, call fails but nothing is checked because this is an unsaved token + {token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, // use an invalid token, appconfig should not even be read (not a terms error) - {token: invalidToken, wantErr: true, readInvoked: false, writeInvoked: false, termsFlag: false}, + {token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, - // terms changed during the auth request - {token: termsChangedToken, wantErr: true, readInvoked: true, writeInvoked: true, termsFlag: true}, + // terms changed for org1 during the auth request + {token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}}, // use of an invalid token does not update the flag - {token: invalidToken, wantErr: true, readInvoked: false, writeInvoked: false, termsFlag: true}, + {token: invalidToken, orgName: "org1", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": false}}, - // use of a valid token resets the flag - {token: validToken, wantErr: false, readInvoked: true, writeInvoked: true, termsFlag: false}, + // use of a valid token for org1 resets the flags + {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, - // use of a valid token again does not update the appConfig - {token: validToken, wantErr: false, readInvoked: true, writeInvoked: false, termsFlag: false}, + // use of a valid token again with org2 does not update anything + {token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, - // terms changed during the actual account request, after auth - {token: termsChangedAfterAuthToken, wantErr: true, readInvoked: true, writeInvoked: true, termsFlag: true}, + // terms changed for org2 during the actual account request, after auth + {token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, - // again terms changed after auth, doesn't update appConfig - {token: termsChangedAfterAuthToken, wantErr: true, readInvoked: true, writeInvoked: false, termsFlag: true}, + // again terms changed after auth for org2, doesn't update appConfig + {token: termsChangedAfterAuthToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, - // terms changed during auth, doesn't update appConfig - {token: termsChangedToken, wantErr: true, readInvoked: true, writeInvoked: false, termsFlag: true}, + // terms changed during auth for org2, doesn't update appConfig + {token: termsChangedToken, orgName: "org2", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, - // valid token, resets the flag - {token: validToken, wantErr: false, readInvoked: true, writeInvoked: true, termsFlag: false}, + // terms changed during auth for org1, now both tokens have the flag, doesn't update appConfig + {token: termsChangedToken, orgName: "org1", wantErr: true, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + + // use a valid token without org, nothing is checked + {token: validToken, orgName: "", wantErr: false, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + + // use an invalid token without org, call fails but nothing is checked because this is an unsaved token + {token: invalidToken, orgName: "", wantErr: true, readInvoked: false, writeTokInvoked: false, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": true, "org2": true}}, + + // valid token for org1, resets that token's flag but not appConfig + {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + + // valid token again for org1, still no write to appConfig + {token: validToken, orgName: "org1", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: false, wantAppCfgTermsFlag: true, wantToksTermsFlags: map[string]bool{"org1": false, "org2": true}}, + + // valid token again for org2, this time resets appConfig + {token: validToken, orgName: "org2", wantErr: false, readInvoked: true, writeTokInvoked: true, + writeAppCfgInvoked: true, wantAppCfgTermsFlag: false, wantToksTermsFlags: map[string]bool{"org1": false, "org2": false}}, } // order of calls is important, and test must not be parallelized as it would @@ -130,6 +196,8 @@ func TestDEPClient(t *testing.T) { for i, c := range cases { t.Logf("case %d", i) + ctx := context.Background() + store := &nanodep_mock.Storage{} store.RetrieveAuthTokensFunc = func(ctx context.Context, name string) (*nanodep_client.OAuth1Tokens, error) { return &nanodep_client.OAuth1Tokens{AccessToken: c.token}, nil @@ -139,7 +207,14 @@ func TestDEPClient(t *testing.T) { } dep := apple_mdm.NewDEPClient(store, ds, logger) - res, err := dep.AccountDetail(ctx, apple_mdm.DEPName) + orgName := c.orgName + if orgName == "" { + // simulate using a new token, not yet saved in the DB, so we pass the + // token directly in the context + ctx = ctxabm.NewContext(ctx, &nanodep_client.OAuth1Tokens{AccessToken: c.token}) + orgName = apple_mdm.UnsavedABMTokenOrgName + } + res, err := dep.AccountDetail(ctx, orgName) if c.wantErr { var httpErr *godep.HTTPError @@ -162,8 +237,9 @@ func TestDEPClient(t *testing.T) { require.True(t, store.RetrieveAuthTokensFuncInvoked) require.True(t, store.RetrieveConfigFuncInvoked) } - checkDSCalled(c.readInvoked, c.writeInvoked) - require.Equal(t, c.termsFlag, appCfg.MDM.AppleBMTermsExpired) + checkDSCalled(c.readInvoked, c.writeTokInvoked, c.writeAppCfgInvoked) + require.Equal(t, c.wantAppCfgTermsFlag, appCfg.MDM.AppleBMTermsExpired) + require.Equal(t, c.wantToksTermsFlags, termsExpiredByOrgName) } } diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 6ce5e38097..53849a6227 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -30,8 +30,44 @@ type PolicyPayload struct { // // Empty string targets all platforms. Platform string - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool + // SoftwareInstallerID is the ID of the software installer that will be installed if the policy fails. + // + // Only applies to team policies. + SoftwareInstallerID *uint +} + +// NewTeamPolicyPayload holds data for team policy creation. +// +// If QueryID is not nil, then Name, Query and Description are ignored +// (such fields are fetched from the queries table). +type NewTeamPolicyPayload struct { + // QueryID allows creating a policy from an existing query. + // + // Using QueryID is the old way of creating policies. + // Use Query, Name and Description instead. + QueryID *uint + // Name is the name of the policy (ignored if QueryID != nil). + Name string + // Query is the policy query (ignored if QueryID != nil). + Query string + // Critical marks the policy as high impact. + Critical bool + // Description is the policy description text (ignored if QueryID != nil). + Description string + // Resolution indicates the steps needed to solve a failing policy. + Resolution string + // Platform is a comma-separated string to indicate the target platforms. + // + // Empty string targets all platforms. + Platform string + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + CalendarEventsEnabled bool + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + SoftwareTitleID *uint } var ( @@ -41,6 +77,9 @@ var ( errPolicyInvalidPlatform = errors.New("invalid policy platform") ) +// PolicyNoTeamID is the team ID of "No team" policies. +const PolicyNoTeamID = uint(0) + // Verify verifies the policy payload is valid. func (p PolicyPayload) Verify() error { if p.QueryID != nil { @@ -109,8 +148,15 @@ type ModifyPolicyPayload struct { Platform *string `json:"platform"` // Critical marks the policy as high impact. Critical *bool `json:"critical" premium:"true"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled *bool `json:"calendar_events_enabled" premium:"true"` + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + // Value 0 will unset the current installer from the policy. + // + // Only applies to team policies. + SoftwareTitleID *uint `json:"software_title_id" premium:"true"` } // Verify verifies the policy payload is valid. @@ -163,7 +209,8 @@ type PolicyData struct { // Empty string targets all platforms. Platform string `json:"platform" db:"platforms"` - CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + SoftwareInstallerID *uint `json:"-" db:"software_installer_id"` UpdateCreateTimestamps } @@ -177,6 +224,14 @@ type Policy struct { // FailingHostCount is the number of hosts this policy fails on. FailingHostCount uint `json:"failing_host_count" db:"failing_host_count"` HostCountUpdatedAt *time.Time `json:"host_count_updated_at" db:"host_count_updated_at"` + + // InstallSoftware is used to trigger installation of a software title + // when this policy fails. + // + // Only applies to team policies. + // + // This field is populated from PolicyData.SoftwareInstallerID. + InstallSoftware *PolicySoftwareTitle `json:"install_software,omitempty"` } type PolicyCalendarData struct { @@ -184,6 +239,11 @@ type PolicyCalendarData struct { Name string `db:"name" json:"name"` } +type PolicySoftwareInstallerData struct { + ID uint `db:"id"` + InstallerID uint `db:"software_installer_id"` +} + // PolicyLite is a stripped down version of the policy. type PolicyLite struct { ID uint `db:"id"` @@ -232,8 +292,22 @@ type PolicySpec struct { // // Empty string targets all platforms. Platform string `json:"platform,omitempty"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool `json:"calendar_events_enabled"` + // SoftwareTitleID is the title ID of the installer associated with this policy. + // When editing a policy, if this is nil or 0 then the title ID is unset from the policy. + SoftwareTitleID *uint `json:"software_title_id"` +} + +// PolicySoftwareTitle contains software title data for policies. +type PolicySoftwareTitle struct { + // SoftwareTitleID is the ID of the title associated to the policy. + SoftwareTitleID uint `json:"software_title_id"` + // Name is the associated installer title name + // (not the package name, but the installed software title). + Name string `json:"name"` } // Verify verifies the policy data is valid. diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 95e585aaf3..c6aeeaa934 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -372,6 +372,7 @@ type SoftwareInstallerPayload struct { URL string `json:"url"` PreInstallQuery string `json:"pre_install_query"` InstallScript string `json:"install_script"` + UninstallScript string `json:"uninstall_script"` PostInstallScript string `json:"post_install_script"` SelfService bool `json:"self_service"` } diff --git a/server/fleet/service.go b/server/fleet/service.go index 0148eb4162..24756ebb6d 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -637,12 +637,21 @@ type Service interface { // InstallSoftwareTitle installs a software title in the given host. InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error + // UninstallSoftwareTitle uninstalls a software title in the given host. + UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error + // GetSoftwareInstallResults gets the results for a particular software install attempt. GetSoftwareInstallResults(ctx context.Context, installUUID string) (*HostSoftwareInstallerResult, error) - // BatchSetSoftwareInstallers replaces the software installers for a - // specified team - BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) error + // BatchSetSoftwareInstallers asynchronously replaces the software installers for a specified team. + // Returns a request UUID that can be used to track an ongoing batch request (with GetBatchSetSoftwareInstallersResult). + BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) (string, error) + // GetBatchSetSoftwareInstallersResult polls for the status of a batch-apply started by BatchSetSoftwareInstallers. + // Return values: + // - 'status': status of the batch-apply which can be "processing", "completed" or "failed". + // - 'message': which contains error information when the status is "failed". + // - 'packages': Contains the list of the applied software packages (when status is "completed"). This is always empty for a dry run. + GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (status string, message string, packages []SoftwarePackageResponse, err error) // SelfServiceInstallSoftwareTitle installs a software title // initiated by the user @@ -653,7 +662,33 @@ type Service interface { GetAppStoreApps(ctx context.Context, teamID *uint) ([]*VPPApp, error) - AddAppStoreApp(ctx context.Context, teamID *uint, appID VPPAppID) error + AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) error + + // MDMAppleProcessOTAEnrollment handles OTA enrollment requests. + // + // Per the [spec][1] OTA enrollment is composed of two phases, each + // phase is a request sent by the host to the same endpoint, but it + // must be handled differently depending on the signatures of the + // request body: + // + // 1. First request has a certificate signed by Apple's CA as the root + // certificate. The server must return a SCEP payload that the device + // will use to get a keypair. Note that this keypair is _different_ + // from the "SCEP identity certificate" that will be generated during + // MDM enrollment, and only used for OTA. + // + // 2. Second request has the SCEP certificate generated in `1` as the + // root certificate, the server responds with a "classic" enrollment + // profile and the device starts the regular enrollment process from there. + // + // The extra steps allows us to grab device information like the serial + // number and hardware uuid to perform operations before the host even + // enrolls in MDM. Currently, this method creates a host records and + // assigns a pre-defined team (based on the enrollSecret provided) to + // the host. + // + // [1]: https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009505-CH1-SW1 + MDMAppleProcessOTAEnrollment(ctx context.Context, certificates []*x509.Certificate, rootSigner *x509.Certificate, enrollSecret string, deviceInfo MDMAppleMachineInfo) ([]byte, error) // ///////////////////////////////////////////////////////////////////////////// // Vulnerabilities @@ -661,7 +696,7 @@ type Service interface { // ListVulnerabilities returns a list of vulnerabilities based on the provided options. ListVulnerabilities(ctx context.Context, opt VulnListOptions) ([]VulnerabilityWithMetadata, *PaginationMetadata, error) // ListVulnerability returns a vulnerability based on the provided CVE. - Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*VulnerabilityWithMetadata, error) + Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *VulnerabilityWithMetadata, known bool, err error) // CountVulnerabilities returns the number of vulnerabilities based on the provided options. CountVulnerabilities(ctx context.Context, opt VulnListOptions) (uint, error) // ListOSVersionsByCVE returns a list of OS versions affected by the provided CVE. @@ -672,7 +707,7 @@ type Service interface { // ///////////////////////////////////////////////////////////////////////////// // Team Policies - NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error) + NewTeamPolicy(ctx context.Context, teamID uint, p NewTeamPolicyPayload) (*Policy, error) ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*Policy, err error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error) @@ -710,9 +745,12 @@ type Service interface { UploadMDMAppleAPNSCert(ctx context.Context, cert io.ReadSeeker) error DeleteMDMAppleAPNSCert(ctx context.Context) error - UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error - GetMDMAppleVPPToken(ctx context.Context) (*VPPTokenInfo, error) - DeleteMDMAppleVPPToken(ctx context.Context) error + UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*VPPTokenDB, error) + UpdateVPPToken(ctx context.Context, id uint, token io.ReadSeeker) (*VPPTokenDB, error) + UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*VPPTokenDB, error) + GetVPPTokens(ctx context.Context) ([]*VPPTokenDB, error) + DeleteVPPToken(ctx context.Context, tokenID uint) error + BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []VPPBatchPayload, dryRun bool) error // GetHostDEPAssignment retrieves the host DEP assignment for the specified host. @@ -794,9 +832,6 @@ type Service interface { // ListMDMAppleDevices lists all the MDM enrolled Apple devices. ListMDMAppleDevices(ctx context.Context) ([]MDMAppleDevice, error) - // ListMDMAppleDEPDevices lists all the devices added to this MDM server in Apple Business Manager (ABM). - ListMDMAppleDEPDevices(ctx context.Context) ([]MDMAppleDEPDevice, error) - // NewMDMAppleDEPKeyPair creates a public private key pair for use with the Apple MDM DEP token. // // Deprecated: NewMDMAppleDEPKeyPair exists only to support a deprecated endpoint. @@ -806,12 +841,21 @@ type Service interface { // private keys to use in ABM to generate an encrypted auth token. GenerateABMKeyPair(ctx context.Context) (*MDMAppleDEPKeyPair, error) - // SaveABMToken reads and validates if the provided token can be + // UploadABMToken reads and validates if the provided token can be // decrypted using the keys stored in the database, then saves the token. - SaveABMToken(ctx context.Context, token io.Reader) error + UploadABMToken(ctx context.Context, token io.Reader) (*ABMToken, error) - // DisableABM disables ABM by soft-deleting the relevant assets - DisableABM(ctx context.Context) error + // ListABMTokens lists all the ABM tokens in Fleet. + ListABMTokens(ctx context.Context) ([]*ABMToken, error) + + // UpdateABMTokenTeams updates the default macOS, iOS, and iPadOS team IDs for a given ABM token. + UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID *uint) (*ABMToken, error) + + // DeleteABMToken deletes the given ABM token. + DeleteABMToken(ctx context.Context, tokenID uint) error + + // RenewABMToken replaces the contents of the given ABM token with the given bytes. + RenewABMToken(ctx context.Context, token io.Reader, tokenID uint) (*ABMToken, error) // EnqueueMDMAppleCommand enqueues a command for execution on the given // devices. Note that a deviceID is the same as a host's UUID. @@ -918,6 +962,12 @@ type Service interface { GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, error) + // CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment + CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error) + + // GetOTAProfile gets the OTA (over-the-air) profile for a given team based on the enroll secret provided. + GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) + /////////////////////////////////////////////////////////////////////////////// // CronSchedulesService @@ -1061,9 +1111,13 @@ type Service interface { // UploadSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) error + UpdateSoftwareInstaller(ctx context.Context, payload *UpdateSoftwareInstallerPayload) (*SoftwareInstaller, error) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error - GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*SoftwareInstaller, error) - DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*DownloadSoftwareInstallerPayload, error) + GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) + GetSoftwareInstallerTokenMetadata(ctx context.Context, token string, titleID uint) (*SoftwareInstallerTokenMetadata, error) + GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*SoftwareInstaller, error) + DownloadSoftwareInstaller(ctx context.Context, skipAuthz bool, alt string, titleID uint, + teamID *uint) (*DownloadSoftwareInstallerPayload, error) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*DownloadSoftwareInstallerPayload, error) // ///////////////////////////////////////////////////////////////////////////// @@ -1072,3 +1126,17 @@ type Service interface { // CalendarWebhook handles incoming calendar callback requests. CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error } + +type KeyValueStore interface { + Set(ctx context.Context, key string, value string, expireTime time.Duration) error + Get(ctx context.Context, key string) (*string, error) +} + +const ( + // BatchSetSoftwareInstallerStatusProcessing is the value returned for an ongoing BatchSetSoftwareInstallers operation. + BatchSetSoftwareInstallersStatusProcessing = "processing" + // BatchSetSoftwareInstallerStatusCompleted is the value returned for a completed BatchSetSoftwareInstallers operation. + BatchSetSoftwareInstallersStatusCompleted = "completed" + // BatchSetSoftwareInstallerStatusFailed is the value returned for a failed BatchSetSoftwareInstallers operation. + BatchSetSoftwareInstallersStatusFailed = "failed" +) diff --git a/server/fleet/software.go b/server/fleet/software.go index efc004ffc5..9045d7e375 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -220,10 +220,14 @@ type SoftwareTitleListOptions struct { // ListOptions cannot be embedded in order to unmarshall with validation. ListOptions ListOptions `url:"list_options"` - TeamID *uint `query:"team_id,optional"` - VulnerableOnly bool `query:"vulnerable,optional"` - AvailableForInstall bool `query:"available_for_install,optional"` - SelfServiceOnly bool `query:"self_service,optional"` + TeamID *uint `query:"team_id,optional"` + VulnerableOnly bool `query:"vulnerable,optional"` + AvailableForInstall bool `query:"available_for_install,optional"` + SelfServiceOnly bool `query:"self_service,optional"` + KnownExploit bool `query:"exploit,optional"` + MinimumCVSS float64 `query:"min_cvss_score,optional"` + MaximumCVSS float64 `query:"max_cvss_score,optional"` + PackagesOnly bool `query:"packages_only,optional"` } type HostSoftwareTitleListOptions struct { @@ -240,9 +244,10 @@ type HostSoftwareTitleListOptions struct { // install (but not currently installed on the host) should be returned. IncludeAvailableForInstall bool - // AvailableForInstall is a query argument that limits the returned software - // titles to those that are available for install on the host. - AvailableForInstall bool `query:"available_for_install,optional"` + // OnlyAvailableForInstall is set via a query argument that limits the + // returned software titles to only those that are available for install on + // the host. + OnlyAvailableForInstall bool `query:"available_for_install,optional"` VulnerableOnly bool `query:"vulnerable,optional"` } @@ -291,6 +296,9 @@ type SoftwareListOptions struct { TeamID *uint `query:"team_id,optional"` VulnerableOnly bool `query:"vulnerable,optional"` IncludeCVEScores bool + KnownExploit bool `query:"exploit,optional"` + MinimumCVSS float64 `query:"min_cvss_score,optional"` + MaximumCVSS float64 `query:"max_cvss_score,optional"` // WithHostCounts indicates that the list of software should include the // counts of hosts per software, and include only those software that have @@ -419,10 +427,12 @@ func SoftwareFromOsqueryRow(name, version, source, vendor, installedPath, releas } type VPPBatchPayload struct { - AppStoreID string `json:"app_store_id"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` } type VPPBatchPayloadWithPlatform struct { - AppStoreID string `json:"app_store_id"` - Platform AppleDevicePlatform `json:"platform"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` + Platform AppleDevicePlatform `json:"platform"` } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index d542840989..09debe0695 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/ptr" ) @@ -19,7 +20,7 @@ type SoftwareInstallerStore interface { Get(ctx context.Context, installerID string) (io.ReadCloser, int64, error) Put(ctx context.Context, installerID string, content io.ReadSeeker) error Exists(ctx context.Context, installerID string) (bool, error) - Cleanup(ctx context.Context, usedInstallerIDs []string) (int, error) + Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error) } // FailingSoftwareInstallerStore is an implementation of SoftwareInstallerStore @@ -39,14 +40,14 @@ func (FailingSoftwareInstallerStore) Exists(ctx context.Context, installerID str return false, errors.New("software installer store not properly configured") } -func (FailingSoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string) (int, error) { +func (FailingSoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error) { // do not fail for the failing store's cleanup, as unlike the other store // methods, this will be called even if software installers are otherwise not // used (by the cron job). return 0, nil } -// SoftwareInstallDetailsResult contains all of the information +// SoftwareInstallDetails contains all of the information // required for a client to pull in and install software from the fleet server type SoftwareInstallDetails struct { // HostID is used for authentication on the backend and should not @@ -60,6 +61,8 @@ type SoftwareInstallDetails struct { PreInstallCondition string `json:"pre_install_condition" db:"pre_install_condition"` // InstallScript is the script to run to install the software package. InstallScript string `json:"install_script" db:"install_script"` + // UninstallScript is the script to run to uninstall the software package. + UninstallScript string `json:"uninstall_script" db:"uninstall_script"` // PostInstallScript is the script to run after installing the software package. PostInstallScript string `json:"post_install_script" db:"post_install_script"` // SelfService indicates the install was initiated by the device user @@ -73,11 +76,17 @@ type SoftwareInstaller struct { // no team. TeamID *uint `json:"team_id" db:"team_id"` // TitleID is the id of the software title associated with the software installer. - TitleID *uint `json:"-" db:"title_id"` + TitleID *uint `json:"title_id" db:"title_id"` // Name is the name of the software package. Name string `json:"name" db:"filename"` + // Extension is the file extension of the software package, inferred from package contents. + Extension string `json:"-" db:"extension"` // Version is the version of the software package. Version string `json:"version" db:"version"` + // Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs). + Platform string `json:"platform" db:"platform"` + // PackageIDList is a comma-separated list of packages extracted from the installer + PackageIDList string `json:"-" db:"package_ids"` // UploadedAt is the time the software package was uploaded. UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` // InstallerID is the unique identifier for the software package metadata in Fleet. @@ -86,10 +95,14 @@ type SoftwareInstaller struct { InstallScript string `json:"install_script" db:"install_script"` // InstallScriptContentID is the ID of the install script content. InstallScriptContentID uint `json:"-" db:"install_script_content_id"` + // UninstallScriptContentID is the ID of the uninstall script content. + UninstallScriptContentID uint `json:"-" db:"uninstall_script_content_id"` // PreInstallQuery is the query to run as a condition to installing the software package. PreInstallQuery string `json:"pre_install_query" db:"pre_install_query"` // PostInstallScript is the script to run after installing the software package. PostInstallScript string `json:"post_install_script" db:"post_install_script"` + // UninstallScript is the script to run to uninstall the software package. + UninstallScript string `json:"uninstall_script" db:"uninstall_script"` // PostInstallScriptContentID is the ID of the post-install script content. PostInstallScriptContentID *uint `json:"-" db:"post_install_script_content_id"` // StorageID is the unique identifier for the software package in the software installer store. @@ -101,6 +114,19 @@ type SoftwareInstaller struct { // SelfService indicates that the software can be installed by the // end user without admin intervention SelfService bool `json:"self_service" db:"self_service"` + // URL is the source URL for this installer (set when uploading via batch/gitops). + URL string `json:"url" db:"url"` +} + +// SoftwarePackageResponse is the response type used when applying software by batch. +type SoftwarePackageResponse struct { + // TeamID is the ID of the team. + // A value of nil means it is scoped to hosts that are assigned to "No team". + TeamID *uint `json:"team_id" db:"team_id"` + // TitleID is the id of the software title associated with the software installer. + TitleID *uint `json:"title_id" db:"title_id"` + // URL is the source URL for this installer (set when uploading via batch/gitops). + URL string `json:"url" db:"url"` } // AuthzType implements authz.AuthzTyper. @@ -108,37 +134,67 @@ func (s *SoftwareInstaller) AuthzType() string { return "installable_entity" } +// PackageIDs turns the comma-separated string from the database into a list (potentially zero-length) of string package IDs +func (s *SoftwareInstaller) PackageIDs() []string { + if s.PackageIDList == "" { + return []string{} + } + + return strings.Split(s.PackageIDList, ",") +} + // SoftwareInstallerStatusSummary represents aggregated status metrics for a software installer package. type SoftwareInstallerStatusSummary struct { // Installed is the number of hosts that have the software package installed. Installed uint `json:"installed" db:"installed"` - // Pending is the number of hosts that have the software package pending installation. - Pending uint `json:"pending" db:"pending"` - // Failed is the number of hosts that have the software package installation failed. - Failed uint `json:"failed" db:"failed"` + // PendingInstall is the number of hosts that have the software package pending installation. + PendingInstall uint `json:"pending_install" db:"pending_install"` + // FailedInstall is the number of hosts that have the software package installation failed. + FailedInstall uint `json:"failed_install" db:"failed_install"` + // PendingUninstall is the number of hosts that have the software package pending installation. + PendingUninstall uint `json:"pending_uninstall" db:"pending_uninstall"` + // FailedInstall is the number of hosts that have the software package installation failed. + FailedUninstall uint `json:"failed_uninstall" db:"failed_uninstall"` } // SoftwareInstallerStatus represents the status of a software installer package on a host. type SoftwareInstallerStatus string const ( - SoftwareInstallerPending SoftwareInstallerStatus = "pending" - SoftwareInstallerFailed SoftwareInstallerStatus = "failed" - SoftwareInstallerInstalled SoftwareInstallerStatus = "installed" + SoftwareInstallPending SoftwareInstallerStatus = "pending_install" + SoftwareInstallFailed SoftwareInstallerStatus = "failed_install" + SoftwareInstalled SoftwareInstallerStatus = "installed" + SoftwareUninstallPending SoftwareInstallerStatus = "pending_uninstall" + SoftwareUninstallFailed SoftwareInstallerStatus = "failed_uninstall" + // SoftwarePending and SoftwareFailed statuses are only used as filters in the API and are not stored in the database. + SoftwarePending SoftwareInstallerStatus = "pending" // either pending_install or pending_uninstall + SoftwareFailed SoftwareInstallerStatus = "failed" // either failed_install or failed_uninstall ) func (s SoftwareInstallerStatus) IsValid() bool { switch s { case - SoftwareInstallerFailed, - SoftwareInstallerInstalled, - SoftwareInstallerPending: + SoftwarePending, + SoftwareFailed, + SoftwareUninstallPending, + SoftwareUninstallFailed, + SoftwareInstallFailed, + SoftwareInstalled, + SoftwareInstallPending: return true default: return false } } +// HostLastInstallData contains data for the last installation of a package on a host. +type HostLastInstallData struct { + // ExecutionID is the installation ID of the package on the host. + ExecutionID string `db:"execution_id"` + // Status is the status of the installation on the host. + Status *SoftwareInstallerStatus `db:"status"` +} + // HostSoftwareInstaller represents a software installer package that has been installed on a host. type HostSoftwareInstallerResult struct { // ID is the unique numerical ID of the result assigned by the datastore. @@ -182,6 +238,12 @@ type HostSoftwareInstallerResult struct { // HostDeletedAt indicates if the data is associated with a // deleted host HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"` + // SoftwareInstallerUserID is the ID of the user that uploaded the software installer. + SoftwareInstallerUserID *uint `json:"-" db:"software_installer_user_id"` + // SoftwareInstallerUserID is the name of the user that uploaded the software installer. + SoftwareInstallerUserName string `json:"-" db:"software_installer_user_name"` + // SoftwareInstallerUserEmail is the email of the user that uploaded the software installer. + SoftwareInstallerUserEmail string `json:"-" db:"software_installer_user_email"` } const ( @@ -191,19 +253,16 @@ const ( SoftwareInstallerInstallFailCopy = "Installing software...\nFailed\n%s" SoftwareInstallerInstallSuccessCopy = "Installing software...\nSuccess\n%s" SoftwareInstallerPostInstallSuccessCopy = "Running script...\nExit code: 0 (Success)\n%s" - // TODO(roberto): this is not true, how do we know that the rollback script was successful? - SoftwareInstallerPostInstallFailCopy = `Running script... + SoftwareInstallerPostInstallFailCopy = `Running script... Exit code: %d (Failed) %s -Rolling back software install... -Rolled back successfully ` ) // EnhanceOutputDetails is used to add extra boilerplate/information to the // output fields so they're easier to consume by users. func (h *HostSoftwareInstallerResult) EnhanceOutputDetails() { - if h.Status == SoftwareInstallerPending { + if h.Status == SoftwareInstallPending { return } @@ -261,6 +320,35 @@ type UploadSoftwareInstallerPayload struct { Platform string BundleIdentifier string SelfService bool + UserID uint + URL string + PackageIDs []string + UninstallScript string + Extension string +} + +type UpdateSoftwareInstallerPayload struct { + // find the installer via these fields + TitleID uint + TeamID *uint + InstallerID uint + // used for authorization and persisted as author + UserID uint + // optional; used for pulling metadata + persisting new installer package to file system + InstallerFile io.ReadSeeker + // update the installer with these fields (*not* PATCH semantics at that point; while the + // associated endpoint is a PATCH, the entire row will be updated to these values, including + // blanks, so make sure they're set from either user input or the existing installer row + // before saving) + InstallScript *string + PreInstallQuery *string + PostInstallScript *string + SelfService *bool + UninstallScript *string + StorageID string + Filename string + Version string + PackageIDs []string } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. @@ -328,10 +416,26 @@ type SoftwarePackageOrApp struct { // Name is only present for software installer packages. Name string `json:"name,omitempty"` - Version string `json:"version"` - SelfService *bool `json:"self_service,omitempty"` - IconURL *string `json:"icon_url"` - LastInstall *HostSoftwareInstall `json:"last_install"` + Version string `json:"version"` + SelfService *bool `json:"self_service,omitempty"` + IconURL *string `json:"icon_url"` + LastInstall *HostSoftwareInstall `json:"last_install"` + LastUninstall *HostSoftwareUninstall `json:"last_uninstall"` + PackageURL *string `json:"package_url"` +} + +type SoftwarePackageSpec struct { + URL string `json:"url"` + SelfService bool `json:"self_service"` + PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` + InstallScript TeamSpecSoftwareAsset `json:"install_script"` + PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` + UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` +} + +type SoftwareSpec struct { + Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"` + AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` } // HostSoftwareInstall represents installation of software on a host from a @@ -349,6 +453,14 @@ type HostSoftwareInstall struct { InstalledAt time.Time `json:"installed_at"` } +// HostSoftwareUninstall represents uninstallation of software from a host with a +// Fleet software installer. +type HostSoftwareUninstall struct { + // ExecutionID is the UUID of the script execution that uninstalled the software. + ExecutionID string `json:"script_execution_id,omitempty"` + UninstalledAt time.Time `json:"uninstalled_at"` +} + // HostSoftwareInstalledVersion represents a version of software installed on a // host. type HostSoftwareInstalledVersion struct { @@ -382,16 +494,24 @@ type HostSoftwareInstallResultPayload struct { func (h *HostSoftwareInstallResultPayload) Status() SoftwareInstallerStatus { switch { case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode == 0: - return SoftwareInstallerInstalled + return SoftwareInstalled case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode != 0: - return SoftwareInstallerFailed + return SoftwareInstallFailed case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode == 0: - return SoftwareInstallerInstalled + return SoftwareInstalled case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode != 0: - return SoftwareInstallerFailed + return SoftwareInstallFailed case h.PreInstallConditionOutput != nil && *h.PreInstallConditionOutput == "": - return SoftwareInstallerFailed + return SoftwareInstallFailed default: - return SoftwareInstallerPending + return SoftwareInstallPending } } + +// SoftwareInstallerTokenMetadata is the metadata stored in Redis for a software installer token. +type SoftwareInstallerTokenMetadata struct { + TitleID uint `json:"title_id"` + TeamID uint `json:"team_id"` +} + +const SoftwareInstallerURLMaxLength = 255 diff --git a/server/fleet/software_test.go b/server/fleet/software_test.go index 236eb896a3..34b583f66f 100644 --- a/server/fleet/software_test.go +++ b/server/fleet/software_test.go @@ -89,7 +89,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "pending status", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerPending, + Status: SoftwareInstallPending, }, expectedPreInstallQueryOutput: nil, expectedOutput: nil, @@ -98,7 +98,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with empty PreInstallQueryOutput", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, PreInstallQueryOutput: ptr.String(""), }, expectedPreInstallQueryOutput: ptr.String(SoftwareInstallerQueryFailCopy), @@ -108,7 +108,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with non-empty PreInstallQueryOutput", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, PreInstallQueryOutput: ptr.String("Some output"), }, expectedPreInstallQueryOutput: ptr.String(SoftwareInstallerQuerySuccessCopy), @@ -118,7 +118,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with nil PreInstallQueryOutput", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, }, expectedPreInstallQueryOutput: nil, expectedOutput: nil, @@ -127,7 +127,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with install scripts disabled", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, InstallScriptExitCode: ptr.Int(-2), Output: ptr.String(""), }, @@ -138,7 +138,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with failed install script", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerFailed, + Status: SoftwareInstallFailed, InstallScriptExitCode: ptr.Int(1), Output: ptr.String("Some install output"), }, @@ -149,7 +149,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with successful install script", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, InstallScriptExitCode: ptr.Int(0), Output: ptr.String("Some install output"), }, @@ -160,7 +160,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with successful post install script", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, InstallScriptExitCode: ptr.Int(0), Output: ptr.String("Some install output"), PostInstallScriptExitCode: ptr.Int(0), @@ -173,7 +173,7 @@ func TestEnhanceOutputDetails(t *testing.T) { { name: "non-pending status with failed post install script", initial: HostSoftwareInstallerResult{ - Status: SoftwareInstallerInstalled, + Status: SoftwareInstalled, InstallScriptExitCode: ptr.Int(0), Output: ptr.String("Some install output"), PostInstallScriptExitCode: ptr.Int(1), diff --git a/server/fleet/statistics.go b/server/fleet/statistics.go index 11a7feeb66..0d88d52def 100644 --- a/server/fleet/statistics.go +++ b/server/fleet/statistics.go @@ -47,6 +47,16 @@ type StatisticsPayload struct { StoredErrors json.RawMessage `json:"storedErrors"` // NumHostsNotResponding is a count of hosts that connect to Fleet successfully but fail to submit results for distributed queries. NumHostsNotResponding int `json:"numHostsNotResponding"` + // Whether server_settings.ai_features_disabled is set to true in the config. + AIFeaturesDisabled bool `json:"aiFeaturesDisabled"` + // Whether at least one team has integrations.google_calendar.enable_calendar_events set to true + MaintenanceWindowsEnabled bool `json:"maintenanceWindowsEnabled"` + // Maintenance windows are considered "configured" if: + // configuration has value set for integrations.google_calendar[0].domain + // configuration has value set for integrations.google_calendar[0].api_key_json + MaintenanceWindowsConfigured bool `json:"maintenanceWindowsConfigured"` + // The number of hosts with Fleet desktop installed. + NumHostsFleetDesktopEnabled int `json:"numHostsFleetDesktopEnabled"` } type HostsCountByOrbitVersion struct { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d5eaa71f8b..68cde3072d 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -8,6 +8,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/ptr" + "golang.org/x/text/unicode/norm" ) const ( @@ -16,8 +17,21 @@ const ( RoleObserver = "observer" RoleObserverPlus = "observer_plus" RoleGitOps = "gitops" + TeamNameNoTeam = "No team" + TeamNameAllTeams = "All teams" ) +const ( + ReservedNameAllTeams = "All teams" + ReservedNameNoTeam = "No team" +) + +// IsReservedTeamName checks if the name provided is a reserved team name +func IsReservedTeamName(name string) bool { + normalizedName := norm.NFC.String(name) + return normalizedName == ReservedNameAllTeams || normalizedName == ReservedNameNoTeam +} + type TeamPayload struct { Name *string `json:"name"` Description *string `json:"description"` @@ -155,7 +169,7 @@ type TeamConfig struct { Features Features `json:"features"` MDM TeamMDM `json:"mdm"` Scripts optjson.Slice[string] `json:"scripts,omitempty"` - Software *TeamSpecSoftware `json:"software,omitempty"` + Software *SoftwareSpec `json:"software,omitempty"` } type TeamWebhookSettings struct { @@ -168,21 +182,9 @@ type TeamSpecSoftwareAsset struct { Path string `json:"path"` } -type TeamSpecSoftware struct { - Packages optjson.Slice[TeamSpecSoftwarePackage] `json:"packages,omitempty"` - AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` -} - type TeamSpecAppStoreApp struct { - AppStoreID string `json:"app_store_id"` -} - -type TeamSpecSoftwarePackage struct { - URL string `json:"url"` - SelfService bool `json:"self_service"` - PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` - InstallScript TeamSpecSoftwareAsset `json:"install_script"` - PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` + AppStoreID string `json:"app_store_id"` + SelfService bool `json:"self_service"` } type TeamMDM struct { @@ -450,7 +452,7 @@ type TeamSpec struct { Scripts optjson.Slice[string] `json:"scripts"` WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` Integrations TeamSpecIntegrations `json:"integrations"` - Software *TeamSpecSoftware `json:"software,omitempty"` + Software *SoftwareSpec `json:"software,omitempty"` } type TeamSpecWebhookSettings struct { diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 01f869645a..4e0803edfe 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -1,6 +1,9 @@ package fleet -import "time" +import ( + "fmt" + "time" +) type VPPAppID struct { // AdamID is a unique identifier assigned to each app in @@ -9,11 +12,18 @@ type VPPAppID struct { Platform AppleDevicePlatform `db:"platform" json:"platform"` } +// VPPAppTeam contains extra metadata injected by fleet +type VPPAppTeam struct { + VPPAppID + + SelfService bool `db:"self_service" json:"self_service"` +} + // VPPApp represents a VPP (Volume Purchase Program) application, // this is used by Apple MDM to manage applications via Apple // Business Manager. type VPPApp struct { - VPPAppID + VPPAppTeam // BundleIdentifier is the unique bundle identifier of the // Application. BundleIdentifier string `db:"bundle_identifier" json:"bundle_identifier"` @@ -23,8 +33,11 @@ type VPPApp struct { Name string `db:"name" json:"name"` // LatestVersion is the latest version of this app. LatestVersion string `db:"latest_version" json:"latest_version"` - TeamID *uint `db:"-" json:"-"` - TitleID uint `db:"title_id" json:"-"` + // TeamID is used for authorization, it must be json serialized to be available + // to the rego script. We don't set it outside authorization anyway, so it + // won't render otherwise. + TeamID *uint `db:"-" json:"team_id,omitempty"` + TitleID uint `db:"title_id" json:"-"` CreatedAt time.Time `db:"created_at" json:"-"` UpdatedAt time.Time `db:"updated_at" json:"-"` @@ -43,6 +56,7 @@ type VPPAppStoreApp struct { LatestVersion string `db:"latest_version" json:"latest_version"` IconURL *string `db:"icon_url" json:"icon_url"` Status *VPPAppStatusSummary `db:"-" json:"status"` + SelfService bool `db:"self_service" json:"self_service"` } // VPPAppStatusSummary represents aggregated status metrics for a VPP app. @@ -54,3 +68,12 @@ type VPPAppStatusSummary struct { // Failed is the number of hosts that have the VPP app installation failed. Failed uint `json:"failed" db:"failed"` } + +type ErrVPPTokenTeamConstraint struct { + Name string + ID *uint +} + +func (e ErrVPPTokenTeamConstraint) Error() string { + return fmt.Sprintf("Error: %q team already has a VPP token. Each team can only have one VPP token.", e.Name) +} diff --git a/server/fleet/vulnerabilities.go b/server/fleet/vulnerabilities.go index a2242f1fbf..9e92fce4a0 100644 --- a/server/fleet/vulnerabilities.go +++ b/server/fleet/vulnerabilities.go @@ -127,6 +127,7 @@ const ( MSRCSource MacOfficeReleaseNotesSource CustomSource + GovalDictionarySource ) type VulnerabilityWithMetadata struct { diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 4487765008..acc90b3a51 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -158,6 +158,18 @@ type MDMWindowsProfilePayload struct { Retries int `db:"retries"` } +func (p MDMWindowsProfilePayload) Equal(other MDMWindowsProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.ProfileUUID == other.ProfileUUID && + p.HostUUID == other.HostUUID && + p.ProfileName == other.ProfileName && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID && + p.Retries == other.Retries +} + type MDMWindowsBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileName string diff --git a/server/live_query/live_query_test.go b/server/live_query/live_query_test.go index f7d684963e..73f64593de 100644 --- a/server/live_query/live_query_test.go +++ b/server/live_query/live_query_test.go @@ -3,6 +3,7 @@ package live_query import ( "context" "testing" + "time" "github.com/fleetdm/fleet/v4/server/datastore/redis" "github.com/fleetdm/fleet/v4/server/fleet" @@ -112,9 +113,14 @@ func testLiveQueryExpiredQuery(t *testing.T, store fleet.LiveQueryStore) { assert.Len(t, queries, 1) assert.Equal(t, map[string]string{"test": "select 1"}, queries) - activeNames, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) - require.NoError(t, err) - require.Equal(t, []string{"test"}, activeNames) + assert.Eventually(t, func() bool { + activeNames, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) + require.NoError(t, err) + if len(activeNames) == 1 && activeNames[0] == "test" { + return true + } + return false + }, 5*time.Second, 100*time.Millisecond) } func testLiveQueryOnlyExpired(t *testing.T, store fleet.LiveQueryStore) { @@ -133,9 +139,11 @@ func testLiveQueryOnlyExpired(t *testing.T, store fleet.LiveQueryStore) { require.NoError(t, err) assert.Len(t, queries, 0) - activeNames, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) - require.NoError(t, err) - require.Len(t, activeNames, 0) + assert.Eventually(t, func() bool { + activeNames, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) + require.NoError(t, err) + return len(activeNames) == 0 + }, 5*time.Second, 100*time.Millisecond) } func testLiveQueryCleanupInactive(t *testing.T, store fleet.LiveQueryStore) { diff --git a/server/live_query/redis_live_query.go b/server/live_query/redis_live_query.go index 8b424ba635..b54c1c1f3a 100644 --- a/server/live_query/redis_live_query.go +++ b/server/live_query/redis_live_query.go @@ -50,11 +50,14 @@ import ( "fmt" "strconv" "strings" + "sync" "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/datastore/redis" "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" redigo "github.com/gomodule/redigo/redis" ) @@ -69,12 +72,56 @@ const ( type redisLiveQuery struct { // connection pool pool fleet.RedisPool + // in memory cache + cache memCache + // in memory cache expiration + cacheExpiration time.Duration + + logger kitlog.Logger +} + +// memCache is an in-memory cache for live queries. It stores the SQL of the +// queries and the active queries set. It also stores the expiration time of the +// cache. +type memCache struct { + sqlCache map[string]string + activeQueriesCache []string + cacheExp time.Time + mu sync.RWMutex +} + +// cacheIsExpired is a thread-safe method to check if the cache is expired. +func (r *redisLiveQuery) cacheIsExpired() bool { + r.cache.mu.RLock() + defer r.cache.mu.RUnlock() + return r.cache.cacheExp.Before(time.Now()) +} + +// getSQLByCampaignID is a thread-safe method to get the SQL of a live query by its +// campaign ID. +func (r *redisLiveQuery) getSQLByCampaignID(campaignID string) (string, bool) { + r.cache.mu.RLock() + defer r.cache.mu.RUnlock() + sql, found := r.cache.sqlCache[campaignID] + return sql, found } // NewRedisQueryResults creates a new Redis implementation of the // QueryResultStore interface using the provided Redis connection pool. -func NewRedisLiveQuery(pool fleet.RedisPool) *redisLiveQuery { - return &redisLiveQuery{pool: pool} +func NewRedisLiveQuery(pool fleet.RedisPool, logger kitlog.Logger, memCacheExp time.Duration) *redisLiveQuery { + return &redisLiveQuery{ + pool: pool, + cache: newMemCache(), + cacheExpiration: memCacheExp, + logger: logger, + } +} + +func newMemCache() memCache { + return memCache{ + sqlCache: make(map[string]string), + activeQueriesCache: make([]string, 0), + } } // generate keys for the bitfield and sql of a query - those always go in pair @@ -147,55 +194,39 @@ func (r *redisLiveQuery) QueriesForHost(hostID uint) (map[string]string, error) } // convert the query name (campaign id) to the key name - for i, name := range names { + keyNames := make([]string, 0, len(names)) + for _, name := range names { tkey, _ := generateKeys(name) - names[i] = tkey + keyNames = append(keyNames, tkey) } - keysBySlot := redis.SplitKeysBySlot(r.pool, names...) + keysBySlot := redis.SplitKeysBySlot(r.pool, keyNames...) queries := make(map[string]string) - expired := make(map[string]struct{}) for _, qkeys := range keysBySlot { - if err := r.collectBatchQueriesForHost(hostID, qkeys, queries, expired); err != nil { + if err := r.collectBatchQueriesForHost(hostID, qkeys, queries); err != nil { return nil, err } } - if len(expired) > 0 { - // a certain percentage of the time so that we don't overwhelm redis with a - // bunch of similar deletion commands at the same time, clean up the - // expired queries. - if time.Now().UnixNano()%cleanupExpiredQueriesModulo == 0 { - names := make([]string, 0, len(expired)) - for k := range expired { - names = append(names, k) - } - // ignore error, best effort removal - _ = r.removeQueryNames(names...) - } - } - return queries, nil } -func (r *redisLiveQuery) collectBatchQueriesForHost(hostID uint, queryKeys []string, queriesByHost map[string]string, expiredQueries map[string]struct{}) error { +func (r *redisLiveQuery) collectBatchQueriesForHost(hostID uint, queryKeys []string, queriesByHost map[string]string) error { conn := redis.ReadOnlyConn(r.pool, r.pool.Get()) defer conn.Close() + if r.cacheIsExpired() { + if err := r.loadCache(); err != nil { + return fmt.Errorf("load cache: %w", err) + } + } + // Pipeline redis calls to check for this host in the bitfield of the // targets of the query. for _, key := range queryKeys { if err := conn.Send("GETBIT", key, hostID); err != nil { return fmt.Errorf("getbit query targets: %w", err) } - - // Additionally get SQL even though we don't yet know whether this query - // is targeted to the host. This allows us to avoid an additional - // roundtrip to the Redis server and likely has little cost due to the - // small number of queries and limited size of SQL - if err := conn.Send("GET", sqlKeyPrefix+key); err != nil { - return fmt.Errorf("get query sql: %w", err) - } } // Flush calls to begin receiving results. @@ -215,27 +246,13 @@ func (r *redisLiveQuery) collectBatchQueriesForHost(hostID uint, queryKeys []str return fmt.Errorf("receive target: %w", err) } - // Be sure to read SQL even if we are not going to include this query. - // Otherwise we will read an incorrect number of returned results from - // the pipeline. - sql, err := redigo.String(conn.Receive()) - if err != nil { - if err != redigo.ErrNil { - return fmt.Errorf("receive sql: %w", err) + if targeted == 1 { + if sql, found := r.getSQLByCampaignID(name); found { + queriesByHost[name] = sql + } else { + level.Warn(r.logger).Log("msg", "live query not found in cache", "name", name) } - - // It is possible the livequery key has expired but was still in the set - // - handle this gracefully by collecting the keys to remove them from - // the set and keep going. - expiredQueries[name] = struct{}{} - continue } - - if targeted == 0 { - // Host not targeted with this query - continue - } - queriesByHost[name] = sql } return nil } @@ -316,14 +333,96 @@ func (r *redisLiveQuery) removeQueryNames(names ...string) error { } func (r *redisLiveQuery) LoadActiveQueryNames() ([]string, error) { + // copyActiveQueries returns a copy of the active queries cache to + // ensure thread safety. + copyActiveQueries := func() []string { + r.cache.mu.RLock() + defer r.cache.mu.RUnlock() + + names := make([]string, len(r.cache.activeQueriesCache)) + copy(names, r.cache.activeQueriesCache) + + return names + } + + if !r.cacheIsExpired() { + return copyActiveQueries(), nil + } + + if err := r.loadCache(); err != nil { + return nil, fmt.Errorf("load cache: %w", err) + } + + return copyActiveQueries(), nil +} + +func (r *redisLiveQuery) loadCache() error { + expiredQueries := make(map[string]struct{}) + sqlCache := make(map[string]string) conn := redis.ConfigureDoer(r.pool, r.pool.Get()) defer conn.Close() - names, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) + activeIDs, err := redigo.Strings(conn.Do("SMEMBERS", activeQueriesKey)) if err != nil && err != redigo.ErrNil { - return nil, err + return fmt.Errorf("get active queries: %w", err) } - return names, nil + + for _, id := range activeIDs { + _, sqlKey := generateKeys(id) + + sql, err := redigo.String(conn.Do("GET", sqlKey)) + if err != nil { + if err != redigo.ErrNil { + return fmt.Errorf("get query sql: %w", err) + } + + // It is possible the livequery key has expired but was still in the set + // - handle this gracefully by collecting the keys to remove them from + // the set and keep going. + expiredQueries[id] = struct{}{} + continue + } + + sqlCache[id] = sql + } + + // remove expired queries from the names list + if len(expiredQueries) > 0 { + trimmedIDs := make([]string, 0, len(activeIDs)-len(expiredQueries)) + for _, name := range activeIDs { + if _, found := expiredQueries[name]; !found { + trimmedIDs = append(trimmedIDs, name) + } + } + activeIDs = trimmedIDs + } + + r.cache.mu.Lock() + r.cache.sqlCache = sqlCache + r.cache.activeQueriesCache = activeIDs + r.cache.cacheExp = time.Now().Add(r.cacheExpiration) + r.cache.mu.Unlock() + + if len(expiredQueries) > 0 { + // a certain percentage of the time so that we don't overwhelm redis with a + // bunch of similar deletion commands at the same time, clean up the + // expired queries. + if time.Now().UnixNano()%cleanupExpiredQueriesModulo == 0 { + names := make([]string, 0, len(expiredQueries)) + for k := range expiredQueries { + names = append(names, k) + } + + go func() { + err = r.removeQueryNames(names...) + if err != nil { + level.Warn(r.logger).Log("msg", "removing expired live queries", "err", err) + } + }() + } + } + + return nil } func (r *redisLiveQuery) CleanupInactiveQueries(ctx context.Context, inactiveCampaignIDs []uint) error { diff --git a/server/live_query/redis_live_query_test.go b/server/live_query/redis_live_query_test.go index d0fe5a91cd..75c6d2cf91 100644 --- a/server/live_query/redis_live_query_test.go +++ b/server/live_query/redis_live_query_test.go @@ -5,6 +5,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/test" + "github.com/go-kit/log" "github.com/stretchr/testify/assert" ) @@ -26,7 +27,7 @@ func TestRedisLiveQuery(t *testing.T) { func setupRedisLiveQuery(t *testing.T, cluster bool) *redisLiveQuery { pool := redistest.SetupRedis(t, "*livequery", cluster, true, true) - return NewRedisLiveQuery(pool) + return NewRedisLiveQuery(pool, log.NewNopLogger(), 0) } func TestMapBitfield(t *testing.T) { diff --git a/server/mdm/apple/AppleIncRootCertificate.cer b/server/mdm/apple/AppleIncRootCertificate.cer new file mode 100644 index 0000000000..8a9ff24741 Binary files /dev/null and b/server/mdm/apple/AppleIncRootCertificate.cer differ diff --git a/server/mdm/apple/AppleIphoneDeviceCA.cer b/server/mdm/apple/AppleIphoneDeviceCA.cer new file mode 100644 index 0000000000..fac79ff89d Binary files /dev/null and b/server/mdm/apple/AppleIphoneDeviceCA.cer differ diff --git a/server/mdm/apple/apple_bm.go b/server/mdm/apple/apple_bm.go new file mode 100644 index 0000000000..eeb2eeddff --- /dev/null +++ b/server/mdm/apple/apple_bm.go @@ -0,0 +1,89 @@ +package apple_mdm + +import ( + "context" + "errors" + "net/http" + + abmctx "github.com/fleetdm/fleet/v4/server/contexts/apple_bm" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/assets" + depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage" + kitlog "github.com/go-kit/log" +) + +// SetABMTokenMetadata uses the provided ABM token to fetch the associated +// metadata and use it to update the rest of the abmToken fields (org name, +// apple ID, renew date). It only sets the data on the struct, it does not +// save it in the DB. +func SetABMTokenMetadata( + ctx context.Context, + abmToken *fleet.ABMToken, + depStorage storage.AllDEPStorage, + ds fleet.Datastore, + logger kitlog.Logger, +) error { + decryptedToken, err := assets.ABMToken(ctx, ds, abmToken.OrganizationName) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting ABM token") + } + + return SetDecryptedABMTokenMetadata(ctx, abmToken, decryptedToken, depStorage, ds, logger) +} + +const UnsavedABMTokenOrgName = "new_abm_token" //nolint:gosec + +func SetDecryptedABMTokenMetadata( + ctx context.Context, + abmToken *fleet.ABMToken, + decryptedToken *depclient.OAuth1Tokens, + depStorage storage.AllDEPStorage, + ds fleet.Datastore, + logger kitlog.Logger, +) error { + depClient := NewDEPClient(depStorage, ds, logger) + + orgName := abmToken.OrganizationName + if orgName == "" { + // Then this is a newly uploaded token (or one migrated from the + // single-token world), which will not be found in the datastore when + // RetrieveAuthTokens tries to find it. Set the token in the context so + // that downstream we know it's not in the datastore. + ctx = abmctx.NewContext(ctx, decryptedToken) + // We don't have an org name, but the depClient expects an org name, so we set this fake one. + orgName = UnsavedABMTokenOrgName + } + + res, err := depClient.AccountDetail(ctx, orgName) + if err != nil { + var authErr *depclient.AuthError + if errors.As(err, &authErr) { + // authentication failure with 401 unauthorized means that the configured + // Apple BM certificate and/or token are invalid. Fail with a 400 Bad + // Request. + msg := err.Error() + if authErr.StatusCode == http.StatusUnauthorized { + msg = "The Apple Business Manager certificate or server token is invalid. Restart Fleet with a valid certificate and token. See https://fleetdm.com/learn-more-about/setup-abm for help." + } + return ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: msg, + InternalErr: err, + }, "apple GET /account request failed with authentication error") + } + return ctxerr.Wrap(ctx, err, "apple GET /account request failed") + } + + if res.AdminID == "" { + // fallback to facilitator ID, as this is the same information but for + // older versions of the Apple API. + // https://github.com/fleetdm/fleet/issues/7515#issuecomment-1346579398 + res.AdminID = res.FacilitatorID + } + + abmToken.OrganizationName = res.OrgName + abmToken.AppleID = res.AdminID + abmToken.RenewAt = decryptedToken.AccessTokenExpiry.UTC() + return nil +} diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 4faa9af1be..81ec4c8f52 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -4,14 +4,16 @@ import ( "bytes" "context" "encoding/json" - "encoding/xml" + "errors" "fmt" "net/url" + "slices" "strings" "text/template" "time" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + ctxabm "github.com/fleetdm/fleet/v4/server/contexts/apple_bm" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/logging" @@ -21,18 +23,14 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log/level" "github.com/google/uuid" + "github.com/hashicorp/go-multierror" + depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage" depsync "github.com/fleetdm/fleet/v4/server/mdm/nanodep/sync" kitlog "github.com/go-kit/log" ) -// DEPName is the identifier/name used in nanodep MySQL storage which -// holds the DEP configuration. -// -// Fleet uses only one DEP configuration set for the whole deployment. -const DEPName = "fleet" - const ( // SCEPPath is Fleet's HTTP path for the SCEP service. SCEPPath = "/mdm/apple/scep" @@ -80,7 +78,7 @@ func ResolveAppleSCEPURL(serverURL string) (string, error) { type DEPService struct { ds fleet.Datastore depStorage nanodep_storage.AllDEPStorage - syncer *depsync.Syncer + depClient *godep.Client logger kitlog.Logger } @@ -142,41 +140,16 @@ func (d *DEPService) createDefaultAutomaticProfile(ctx context.Context) error { return nil } -// RegisterProfileWithAppleDEPServer registers the enrollment profile in -// Apple's servers via the DEP API, so it can be used for assignment. If -// setupAsst is nil, the default profile is registered. It assigns the -// up-to-date dynamic settings such as the server URL and MDM SSO URL if -// end-user authentication is enabled for that team/no-team. -func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant) error { - appCfg, err := d.ds.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "fetching app config") - } - - // must always get the default profile, because the authentication token is - // defined on that profile. - defaultProf, err := d.ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic) - if err != nil { - return ctxerr.Wrap(ctx, err, "fetching default profile") - } - - enrollURL, err := EnrollURL(defaultProf.Token, appCfg) - if err != nil { - return ctxerr.Wrap(ctx, err, "generating enroll URL") - } - - var rawJSON json.RawMessage - if defaultProf.DEPProfile != nil { - rawJSON = *defaultProf.DEPProfile - } - if setupAsst != nil { - rawJSON = setupAsst.Profile - } +// CreateDefaultAutomaticProfile creates the default automatic enrollment profile in the DB. +func (d *DEPService) CreateDefaultAutomaticProfile(ctx context.Context) error { + return d.createDefaultAutomaticProfile(ctx) +} +func (d *DEPService) buildJSONProfile(ctx context.Context, setupAsstJSON json.RawMessage, appCfg *fleet.AppConfig, team *fleet.Team, enrollURL string) (*godep.Profile, error) { var jsonProf godep.Profile jsonProf.IsMDMRemovable = true // the default value defined by Apple is true - if err := json.Unmarshal(rawJSON, &jsonProf); err != nil { - return ctxerr.Wrap(ctx, err, "unmarshalling DEP profile") + if err := json.Unmarshal(setupAsstJSON, &jsonProf); err != nil { + return nil, ctxerr.Wrap(ctx, err, "unmarshalling DEP profile") } // if configuration_web_url is set, this setting is completely managed by the @@ -207,34 +180,174 @@ func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team // enable_release_device_manually is true. jsonProf.AwaitDeviceConfigured = true - depClient := NewDEPClient(d.depStorage, d.ds, d.logger) - res, err := depClient.DefineProfile(ctx, DEPName, &jsonProf) + return &jsonProf, nil +} + +// RegisterProfileWithAppleDEPServer registers the enrollment profile in +// Apple's servers via the DEP API, so it can be used for assignment. If +// setupAsst is nil, the default profile is registered. It assigns the +// up-to-date dynamic settings such as the server URL and MDM SSO URL if +// end-user authentication is enabled for that team/no-team. +// +// It does that registration for all tokens associated in any way with that +// team - that is, if DEP hosts are part of that team then the token used to +// discover those hosts will be used to register the profile, and if a token +// has that team as default team for a platform, it will also be used to +// register the profile. +// +// On success, it returns the profile uuid and timestamp for the specific token +// of interest to the caller (identified by its organization name). +func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokenOrgName string) (string, time.Time, error) { + appCfg, err := d.ds.AppConfig(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "apple POST /profile request failed") + return "", time.Time{}, ctxerr.Wrap(ctx, err, "fetching app config") } + // must always get the default profile, because the authentication token is + // defined on that profile. + defaultProf, err := d.ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic) + if err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "fetching default profile") + } + + enrollURL, err := EnrollURL(defaultProf.Token, appCfg) + if err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "generating enroll URL") + } + + var rawJSON json.RawMessage + var requestedTokenModTime time.Time + if defaultProf.DEPProfile != nil { + rawJSON = *defaultProf.DEPProfile + requestedTokenModTime = defaultProf.UpdatedAt + } if setupAsst != nil { - setupAsst.ProfileUUID = res.ProfileUUID - if err := d.ds.SetMDMAppleSetupAssistantProfileUUID(ctx, setupAsst.TeamID, res.ProfileUUID); err != nil { - return ctxerr.Wrap(ctx, err, "save setup assistant profile UUID") + rawJSON = setupAsst.Profile + requestedTokenModTime = setupAsst.UploadedAt + } + + jsonProf, err := d.buildJSONProfile(ctx, rawJSON, appCfg, team, enrollURL) + if err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "building json profile") + } + + depClient := NewDEPClient(d.depStorage, d.ds, d.logger) + // Get all relevant org names + var tmID *uint + if team != nil { + tmID = &team.ID + } + + orgNames, err := d.ds.GetABMTokenOrgNamesAssociatedWithTeam(ctx, tmID) + if err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "getting org names for team to register profile") + } + + if len(orgNames) == 0 { + d.logger.Log("msg", "skipping defining profile for team with no relevant ABM token") + return "", time.Time{}, nil + } + + var requestedTokenProfileUUID string + for _, orgName := range orgNames { + res, err := depClient.DefineProfile(ctx, orgName, jsonProf) + if err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "apple POST /profile request failed") } - } else { - var tmID *uint - if team != nil { - tmID = &team.ID + + if setupAsst != nil { + if err := d.ds.SetMDMAppleSetupAssistantProfileUUID(ctx, setupAsst.TeamID, res.ProfileUUID, orgName); err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "save setup assistant profile UUID") + } + } else { + if err := d.ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, tmID, res.ProfileUUID, orgName); err != nil { + return "", time.Time{}, ctxerr.Wrap(ctx, err, "save default setup assistant profile UUID") + } } - if err := d.ds.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, tmID, res.ProfileUUID); err != nil { - return ctxerr.Wrap(ctx, err, "save default setup assistant profile UUID") + if orgName == abmTokenOrgName { + requestedTokenProfileUUID = res.ProfileUUID } } + return requestedTokenProfileUUID, requestedTokenModTime, nil +} + +// ValidateSetupAssistant validates the setup assistant by sending the profile to the DefineProfile +// Apple API. +func (d *DEPService) ValidateSetupAssistant(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokenOrgName string) error { + appCfg, err := d.ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching app config") + } + + // must always get the default profile, because the authentication token is + // defined on that profile. + defaultProf, err := d.ds.GetMDMAppleEnrollmentProfileByType(ctx, fleet.MDMAppleEnrollmentTypeAutomatic) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching default profile") + } + + enrollURL, err := EnrollURL(defaultProf.Token, appCfg) + if err != nil { + return ctxerr.Wrap(ctx, err, "generating enroll URL") + } + + rawJSON := setupAsst.Profile + + jsonProf, err := d.buildJSONProfile(ctx, rawJSON, appCfg, team, enrollURL) + if err != nil { + return ctxerr.Wrap(ctx, err, "building json profile") + } + + depClient := NewDEPClient(d.depStorage, d.ds, d.logger) + // Get all relevant org names + var tmID *uint + if team != nil { + tmID = &team.ID + } + + orgNames, err := d.ds.GetABMTokenOrgNamesAssociatedWithTeam(ctx, tmID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting org names for team to register profile") + } + + if len(orgNames) == 0 { + // Then check to see if there are any tokens at all. If there is only 1, we assume we can + // use it (the vast majority of deployments will only have a single token). + toks, err := d.ds.ListABMTokens(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "listing ABM tokens") + } + + if len(toks) != 1 { + return ctxerr.New(ctx, "No relevant ABM tokens found. Please set this team as a default team for an ABM token.") + } + + orgNames = append(orgNames, toks[0].OrganizationName) + } + + for _, orgName := range orgNames { + _, err := depClient.DefineProfile(ctx, orgName, jsonProf) + if err != nil { + var httpErr *godep.HTTPError + if errors.As(err, &httpErr) { + // We can count on this working because of how the godep.HTTPerror Error() method + // formats its output. + return ctxerr.Errorf(ctx, "Couldn't upload. %s", string(httpErr.Body)) + } + + return ctxerr.Wrap(ctx, err, "sending profile to Apple failed") + } + } + return nil } // EnsureDefaultSetupAssistant ensures that the default Setup Assistant profile // is created and registered with Apple for the provided team/no-team (if team -// is nil), and returns its profile UUID. It does not re-define the profile if -// it already exists and registered. -func (d *DEPService) EnsureDefaultSetupAssistant(ctx context.Context, team *fleet.Team) (string, time.Time, error) { +// is nil) using the specified ABM token, and returns its profile UUID. It does +// not re-define the profile if it already exists and registered for that +// token. +func (d *DEPService) EnsureDefaultSetupAssistant(ctx context.Context, team *fleet.Team, abmTokenOrgName string) (string, time.Time, error) { // the first step is to ensure that the default profile entry exists in the // mdm_apple_enrollment_profiles table. When we create it there we also // create the authentication token to retrieve enrollment profiles, and @@ -250,23 +363,20 @@ func (d *DEPService) EnsureDefaultSetupAssistant(ctx context.Context, team *flee } // now that the default automatic profile is created and a token generated, - // check if the default profile was registered with Apple for that team. + // check if the default profile was registered with Apple for the ABM token. var tmID *uint if team != nil { tmID = &team.ID } - profUUID, modTime, err := d.ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID) + profUUID, modTime, err := d.ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, abmTokenOrgName) if err != nil && !fleet.IsNotFound(err) { return "", time.Time{}, ctxerr.Wrap(ctx, err, "get default setup assistant profile uuid") } if profUUID == "" { d.logger.Log("msg", "default DEP profile not set, registering") - if err := d.RegisterProfileWithAppleDEPServer(ctx, team, nil); err != nil { - return "", time.Time{}, ctxerr.Wrap(ctx, err, "register default setup assistant with Apple") - } - profUUID, modTime, err = d.ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID) + profUUID, modTime, err = d.RegisterProfileWithAppleDEPServer(ctx, team, nil, abmTokenOrgName) if err != nil { - return "", time.Time{}, ctxerr.Wrap(ctx, err, "get default setup assistant profile uuid after registering") + return "", time.Time{}, ctxerr.Wrap(ctx, err, "register default setup assistant with Apple") } } return profUUID, modTime, nil @@ -274,14 +384,16 @@ func (d *DEPService) EnsureDefaultSetupAssistant(ctx context.Context, team *flee // EnsureCustomSetupAssistantIfExists ensures that the custom Setup Assistant // profile associated with the provided team (or no team) is registered with -// Apple, and returns its profile UUID. It does not re-define the profile if it -// is already registered. If no custom setup assistant exists, it returns an -// empty string and timestamp and no error. -func (d *DEPService) EnsureCustomSetupAssistantIfExists(ctx context.Context, team *fleet.Team) (string, time.Time, error) { +// Apple for the specified ABM token, and returns its profile UUID. It does not +// re-define the profile if it is already registered for that token. If no +// custom setup assistant exists, it returns an empty string and timestamp and +// no error. +func (d *DEPService) EnsureCustomSetupAssistantIfExists(ctx context.Context, team *fleet.Team, abmTokenOrgName string) (string, time.Time, error) { var tmID *uint if team != nil { tmID = &team.ID } + asst, err := d.ds.GetMDMAppleSetupAssistant(ctx, tmID) if err != nil { if fleet.IsNotFound(err) { @@ -291,63 +403,124 @@ func (d *DEPService) EnsureCustomSetupAssistantIfExists(ctx context.Context, tea return "", time.Time{}, err } - if asst.ProfileUUID == "" { - if err := d.RegisterProfileWithAppleDEPServer(ctx, team, asst); err != nil { + // if we get here, there IS a custom setup assistant, so get its profile UUID + profileUUID, modTime, err := d.ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, tmID, abmTokenOrgName) + if err != nil && !fleet.IsNotFound(err) { + return "", time.Time{}, err + } + + if profileUUID == "" { + // registers the profile for all tokens associated with the team + profileUUID, modTime, err = d.RegisterProfileWithAppleDEPServer(ctx, team, asst, abmTokenOrgName) + if err != nil { return "", time.Time{}, err } } - return asst.ProfileUUID, asst.UploadedAt, nil + return profileUUID, modTime, nil } func (d *DEPService) RunAssigner(ctx context.Context) error { - // get the Apple BM default team - appCfg, err := d.ds.AppConfig(ctx) + syncerLogger := logging.NewNanoDEPLogger(kitlog.With(d.logger, "component", "nanodep-syncer")) + teams, err := d.ds.ListTeams( + ctx, fleet.TeamFilter{ + User: &fleet.User{ + GlobalRole: ptr.String(fleet.RoleAdmin), + }, + }, fleet.ListOptions{}, + ) if err != nil { - return err + return ctxerr.Wrap(ctx, err, "listing teams") } - var appleBMTeam *fleet.Team - if appCfg.MDM.AppleBMDefaultTeam != "" { - tm, err := d.ds.TeamByName(ctx, appCfg.MDM.AppleBMDefaultTeam) - if err != nil && !fleet.IsNotFound(err) { - return err + + teamsByID := make(map[uint]*fleet.Team, len(teams)) + for _, tm := range teams { + teamsByID[tm.ID] = tm + } + + tokens, err := d.ds.ListABMTokens(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "listing ABM tokens") + } + + var result error + for _, token := range tokens { + var macOSTeam, iosTeam, ipadTeam *fleet.Team + + if token.MacOSDefaultTeamID != nil { + macOSTeam = teamsByID[*token.MacOSDefaultTeamID] } - appleBMTeam = tm - } - // ensure the default (fallback) setup assistant profile exists, registered - // with Apple DEP. - _, defModTime, err := d.EnsureDefaultSetupAssistant(ctx, appleBMTeam) - if err != nil { - return err - } + if token.IOSDefaultTeamID != nil { + iosTeam = teamsByID[*token.IOSDefaultTeamID] + } - // if the team/no-team has a custom setup assistant, ensure it is registered - // with Apple DEP. - customUUID, customModTime, err := d.EnsureCustomSetupAssistantIfExists(ctx, appleBMTeam) - if err != nil { - return err - } + if token.IPadOSDefaultTeamID != nil { + ipadTeam = teamsByID[*token.IPadOSDefaultTeamID] + } - // get the modification timestamp of the effective profile (custom or default) - effectiveProfModTime := defModTime - if customUUID != "" { - effectiveProfModTime = customModTime - } + teams := []*fleet.Team{macOSTeam, iosTeam, ipadTeam} + for _, team := range teams { + // ensure the default (fallback) setup assistant profile exists, registered + // with Apple DEP. + _, defModTime, err := d.EnsureDefaultSetupAssistant(ctx, team, token.OrganizationName) + if err != nil { + result = multierror.Append(result, err) + continue + } - cursor, cursorModTime, err := d.depStorage.RetrieveCursor(ctx, DEPName) - if err != nil { - return err - } + // if the team/no-team has a custom setup assistant, ensure it is registered + // with Apple DEP. + customUUID, customModTime, err := d.EnsureCustomSetupAssistantIfExists(ctx, team, token.OrganizationName) + if err != nil { + result = multierror.Append(result, err) + continue + } - // If the effective profile was changed since last sync then we clear - // the cursor and perform a full sync of all devices and profile assigning. - if cursor != "" && effectiveProfModTime.After(cursorModTime) { - d.logger.Log("msg", "clearing device syncer cursor") - if err := d.depStorage.StoreCursor(ctx, DEPName, ""); err != nil { - return err + // get the modification timestamp of the effective profile (custom or default) + effectiveProfModTime := defModTime + if customUUID != "" { + effectiveProfModTime = customModTime + } + + cursor, cursorModTime, err := d.depStorage.RetrieveCursor(ctx, token.OrganizationName) + if err != nil { + result = multierror.Append(result, err) + continue + } + + if cursor != "" && effectiveProfModTime.After(cursorModTime) { + d.logger.Log("msg", "clearing device syncer cursor", "org_name", token.OrganizationName) + if err := d.depStorage.StoreCursor(ctx, token.OrganizationName, ""); err != nil { + result = multierror.Append(result, err) + continue + } + } + + } + + syncer := depsync.NewSyncer( + d.depClient, + token.OrganizationName, + d.depStorage, + depsync.WithLogger(syncerLogger), + depsync.WithCallback(func(ctx context.Context, isFetch bool, resp *godep.DeviceResponse) error { + // the nanodep syncer just logs the error of the callback, so in order to + // capture it we need to do this here. + err := d.processDeviceResponse(ctx, resp, token.ID, token.OrganizationName, macOSTeam, iosTeam, ipadTeam) + if err != nil { + ctxerr.Handle(ctx, err) + } + return err + }), + ) + + if err := syncer.Run(ctx); err != nil { + result = multierror.Append(result, err) + continue } } - return d.syncer.Run(ctx) + + return result } func NewDEPService( @@ -355,36 +528,28 @@ func NewDEPService( depStorage nanodep_storage.AllDEPStorage, logger kitlog.Logger, ) *DEPService { - depClient := NewDEPClient(depStorage, ds, logger) depSvc := &DEPService{ depStorage: depStorage, logger: logger, ds: ds, + depClient: NewDEPClient(depStorage, ds, logger), } - depSvc.syncer = depsync.NewSyncer( - depClient, - DEPName, - depStorage, - depsync.WithLogger(logging.NewNanoDEPLogger(kitlog.With(logger, "component", "nanodep-syncer"))), - depsync.WithCallback(func(ctx context.Context, isFetch bool, resp *godep.DeviceResponse) error { - // the nanodep syncer just logs the error of the callback, so in order to - // capture it we need to do this here. - err := depSvc.processDeviceResponse(ctx, depClient, resp) - if err != nil { - ctxerr.Handle(ctx, err) - } - return err - }), - ) - return depSvc } // processDeviceResponse processes the device response from the device sync // DEP API endpoints and assigns the profile UUID associated with the DEP // client DEP name. -func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep.Client, resp *godep.DeviceResponse) error { +func (d *DEPService) processDeviceResponse( + ctx context.Context, + resp *godep.DeviceResponse, + abmTokenID uint, + abmOrganizationName string, + macOSTeam *fleet.Team, + iosTeam *fleet.Team, + ipadTeam *fleet.Team, +) error { if len(resp.Devices) < 1 { // no devices means we can't assign anything return nil @@ -452,7 +617,7 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep return ctxerr.Wrap(ctx, err, "deleting DEP assignments") } - n, defaultABMTeamID, err := d.ds.IngestMDMAppleDevicesFromDEPSync(ctx, addedDevices) + n, err := d.ds.IngestMDMAppleDevicesFromDEPSync(ctx, addedDevices, abmTokenID, macOSTeam, iosTeam, ipadTeam) switch { case err != nil: level.Error(kitlog.With(d.logger)).Log("err", err) @@ -463,54 +628,68 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep level.Debug(kitlog.With(d.logger)).Log("msg", "no DEP hosts to add") } + level.Debug(kitlog.With(d.logger)).Log("msg", "devices to assign DEP profiles", "to_add", len(addedDevices), "to_remove", deletedSerials, "to_modify", modifiedSerials) + // at this point, the hosts rows are created for the devices, with the // correct team_id, so we know what team-specific profile needs to be applied. // // collect a map of all the profiles => serials we need to assign. profileToDevices := map[string][]godep.Device{} + var iosTeamID, macOSTeamID, ipadTeamID *uint + if iosTeam != nil { + iosTeamID = &iosTeam.ID + } + if macOSTeam != nil { + macOSTeamID = &macOSTeam.ID + } + if ipadTeam != nil { + ipadTeamID = &ipadTeam.ID + } // each new device should be assigned the DEP profile of the default // ABM team as configured by the IT admin. - if len(addedDevices) > 0 { - level.Debug(kitlog.With(d.logger)).Log("msg", "gathering added serials to assign devices", "len", len(addedDevices)) - profUUID, err := d.getProfileUUIDForTeam(ctx, defaultABMTeamID) - if err != nil { - return ctxerr.Wrapf(ctx, err, "getting profile for default team with id: %v", defaultABMTeamID) + devicesByTeam := map[*uint][]godep.Device{} + for _, newDevice := range addedDevices { + var teamID *uint + switch newDevice.DeviceFamily { + case "iPhone": + teamID = iosTeamID + case "iPad": + teamID = ipadTeamID + default: + teamID = macOSTeamID } + devicesByTeam[teamID] = append(devicesByTeam[teamID], newDevice) - profileToDevices[profUUID] = addedDevices - } else { - level.Debug(kitlog.With(d.logger)).Log("msg", "no added devices to assign DEP profiles") } - // for all other hosts we received, find out the right DEP profile to assign, based on the team. - if len(existingSerials) > 0 { - level.Debug(kitlog.With(d.logger)).Log("msg", "gathering existing serials to assign devices", "len", len(existingSerials)) - devicesByTeam := map[*uint][]godep.Device{} - hosts := []fleet.Host{} - for _, host := range existingSerials { - dd, ok := modifiedDevices[host.HardwareSerial] - if !ok { - return ctxerr.Errorf(ctx, "serial %s coming from ABM is in the databse, but it's not in the list of modified devices", host.HardwareSerial) - } - hosts = append(hosts, *host) - devicesByTeam[host.TeamID] = append(devicesByTeam[host.TeamID], dd) + // for all other hosts we received, find out the right DEP profile to + // assign, based on the team. + existingHosts := []fleet.Host{} + for _, existingHost := range existingSerials { + dd, ok := modifiedDevices[existingHost.HardwareSerial] + if !ok { + level.Error(kitlog.With(d.logger)).Log("msg", "serial coming from ABM is in the databse, but it's not in the list of modified devices", "serial", existingHost.HardwareSerial) + continue } - for team, devices := range devicesByTeam { - profUUID, err := d.getProfileUUIDForTeam(ctx, team) - if err != nil { - return ctxerr.Wrapf(ctx, err, "getting profile for team with id: %v", team) - } + existingHosts = append(existingHosts, *existingHost) + devicesByTeam[existingHost.TeamID] = append(devicesByTeam[existingHost.TeamID], dd) + } - profileToDevices[profUUID] = append(profileToDevices[profUUID], devices...) + // assign the profile to each device + for team, devices := range devicesByTeam { + profUUID, err := d.getProfileUUIDForTeam(ctx, team, abmOrganizationName) + if err != nil { + return ctxerr.Wrapf(ctx, err, "getting profile for team with id: %v", team) } - if err := d.ds.UpsertMDMAppleHostDEPAssignments(ctx, hosts); err != nil { - return ctxerr.Wrap(ctx, err, "upserting dep assignment for existing device") - } + profileToDevices[profUUID] = append(profileToDevices[profUUID], devices...) + } - } else { - level.Debug(kitlog.With(d.logger)).Log("msg", "no existing devices to assign DEP profiles") + if len(existingHosts) > 0 { + if err := d.ds.UpsertMDMAppleHostDEPAssignments(ctx, existingHosts, abmTokenID); err != nil { + return ctxerr.Wrap(ctx, err, "upserting dep assignment for existing devices") + } } // keep track of the serials we're going to skip for all profiles in @@ -531,7 +710,6 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep } logger := kitlog.With(d.logger, "profile_uuid", profUUID) - level.Info(logger).Log("msg", "calling DEP client to assign profile", "profile_uuid", profUUID) skipSerials, assignSerials, err := d.ds.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) if err != nil { @@ -547,25 +725,29 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep continue } - apiResp, err := depClient.AssignProfile(ctx, DEPName, profUUID, assignSerials...) - if err != nil { - level.Error(logger).Log( - "msg", "assign profile", + for orgName, serials := range assignSerials { + apiResp, err := d.depClient.AssignProfile(ctx, orgName, profUUID, serials...) + if err != nil { + // only log the error so the failure can be recorded + // below in UpdateHostDEPAssignProfileResponses and + // the proper cooldowns are applied + level.Error(logger).Log( + "msg", "assign profile", + "devices", len(assignSerials), + "err", err, + ) + } + + logs := []interface{}{ + "msg", "profile assigned", "devices", len(assignSerials), - "err", err, - ) - return fmt.Errorf("assign profile: %w", err) - } + } + logs = append(logs, logCountsForResults(apiResp.Devices)...) + level.Info(logger).Log(logs...) - logs := []interface{}{ - "msg", "profile assigned", - "devices", len(assignSerials), - } - logs = append(logs, logCountsForResults(apiResp.Devices)...) - level.Info(logger).Log(logs...) - - if err := d.ds.UpdateHostDEPAssignProfileResponses(ctx, apiResp); err != nil { - return ctxerr.Wrap(ctx, err, "update host dep assign profile responses") + if err := d.ds.UpdateHostDEPAssignProfileResponses(ctx, apiResp); err != nil { + return ctxerr.Wrap(ctx, err, "update host dep assign profile responses") + } } } @@ -576,7 +758,7 @@ func (d *DEPService) processDeviceResponse(ctx context.Context, depClient *godep return nil } -func (d *DEPService) getProfileUUIDForTeam(ctx context.Context, tmID *uint) (string, error) { +func (d *DEPService) getProfileUUIDForTeam(ctx context.Context, tmID *uint, abmTokenOrgName string) (string, error) { var appleBMTeam *fleet.Team if tmID != nil { tm, err := d.ds.Team(ctx, *tmID) @@ -587,12 +769,12 @@ func (d *DEPService) getProfileUUIDForTeam(ctx context.Context, tmID *uint) (str } // get profile uuid of team or default - profUUID, _, err := d.EnsureCustomSetupAssistantIfExists(ctx, appleBMTeam) + profUUID, _, err := d.EnsureCustomSetupAssistantIfExists(ctx, appleBMTeam, abmTokenOrgName) if err != nil { - return "", fmt.Errorf("ensure setup assistant for team %v: %w", tmID, err) + return "", fmt.Errorf("ensure setup assistant for team: %w", err) } if profUUID == "" { - profUUID, _, err = d.EnsureDefaultSetupAssistant(ctx, appleBMTeam) + profUUID, _, err = d.EnsureDefaultSetupAssistant(ctx, appleBMTeam, abmTokenOrgName) if err != nil { return "", fmt.Errorf("ensure default setup assistant: %w", err) } @@ -620,34 +802,76 @@ func logCountsForResults(deviceResults map[string]string) (out []interface{}) { } // NewDEPClient creates an Apple DEP API HTTP client based on the provided -// storage that will flag the AppConfig's AppleBMTermsExpired field -// whenever the status of the terms changes. -func NewDEPClient(storage godep.ClientStorage, appCfgUpdater fleet.AppConfigUpdater, logger kitlog.Logger) *godep.Client { +// storage that will flag the ABM token's terms expired field and the +// AppConfig's AppleBMTermsExpired field whenever the status of the terms +// changes. +func NewDEPClient(storage godep.ClientStorage, updater fleet.ABMTermsUpdater, logger kitlog.Logger) *godep.Client { return godep.NewClient(storage, fleethttp.NewClient(), godep.WithAfterHook(func(ctx context.Context, reqErr error) error { + // to check for ABM terms expired, we must have an ABM token organization + // name and NOT a raw ABM token in the context (as the presence of a raw + // ABM token means that the token is new, hasn't been saved in the DB yet + // so no point checking for the terms expired as we don't have a row in + // abm_tokens to save that flag). + orgName := depclient.GetName(ctx) + if _, rawTokenPresent := ctxabm.FromContext(ctx); rawTokenPresent || orgName == "" { + return reqErr + } + // if the request failed due to terms not signed, or if it succeeded, - // update the app config flag accordingly. If it failed for any other - // reason, do not update the flag. + // update the ABM token's (and possibly the app config's) flag accordingly. + // If it failed for any other reason, do not update the flag. termsExpired := reqErr != nil && godep.IsTermsNotSigned(reqErr) if reqErr == nil || termsExpired { - appCfg, err := appCfgUpdater.AppConfig(ctx) + // get the count of tokens with the flag still set + count, err := updater.CountABMTokensWithTermsExpired(ctx) + if err != nil { + level.Error(logger).Log("msg", "Apple DEP client: failed to get count of tokens with terms expired", "err", err) + return reqErr + } + + // get the appconfig for the global flag + appCfg, err := updater.AppConfig(ctx) if err != nil { level.Error(logger).Log("msg", "Apple DEP client: failed to get app config", "err", err) return reqErr } + // on API call success, if the global terms expired flag is not set and + // the count is 0, no need to do anything else (it means this ABM token + // already had the flag cleared). + if reqErr == nil && count == 0 && !appCfg.MDM.AppleBMTermsExpired { + return reqErr + } + + // otherwise, update the specific ABM token's flag + wasSet, err := updater.SetABMTokenTermsExpiredForOrgName(ctx, orgName, termsExpired) + if err != nil { + level.Error(logger).Log("msg", "Apple DEP client: failed to update terms expired of ABM token", "err", err) + return reqErr + } + + // update the count of ABM tokens with the flag set accordingly + stillSetCount := count + if wasSet && !termsExpired { + stillSetCount-- + } else if !wasSet && termsExpired { + stillSetCount++ + } + var mustSaveAppCfg bool - if termsExpired && !appCfg.MDM.AppleBMTermsExpired { + if stillSetCount > 0 && !appCfg.MDM.AppleBMTermsExpired { // flag the AppConfig that the terms have changed and must be accepted + // for at least one token appCfg.MDM.AppleBMTermsExpired = true mustSaveAppCfg = true - } else if reqErr == nil && appCfg.MDM.AppleBMTermsExpired { - // flag the AppConfig that the terms have been accepted + } else if stillSetCount == 0 && appCfg.MDM.AppleBMTermsExpired { + // flag the AppConfig that the terms have been accepted for all tokens appCfg.MDM.AppleBMTermsExpired = false mustSaveAppCfg = true } if mustSaveAppCfg { - if err := appCfgUpdater.SaveAppConfig(ctx, appCfg); err != nil { + if err := updater.SaveAppConfig(ctx, appCfg); err != nil { level.Error(logger).Log("msg", "Apple DEP client: failed to save app config", "err", err) } level.Info(logger).Log("msg", "Apple DEP client: updated app config Terms Expired flag", @@ -658,11 +882,71 @@ func NewDEPClient(storage godep.ClientStorage, appCfgUpdater fleet.AppConfigUpda })) } +var funcMap = map[string]any{ + "xml": mobileconfig.XMLEscapeString, +} + +var OTASCEPTemplate = template.Must(template.New("").Funcs(funcMap).Parse(` + + + + PayloadVersion + 1 + PayloadType + Configuration + PayloadIdentifier + Ignored + PayloadUUID + Ignored + PayloadContent + + + PayloadContent + + Key Type + RSA + Challenge + {{ .SCEPChallenge | xml }} + Key Usage + 5 + Keysize + 2048 + URL + {{ .SCEPURL }} + Subject + + + + O + Fleet + + + + + CN + Fleet Identity + + + + + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.scep + PayloadType + com.apple.security.scep + PayloadUUID + BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1 + PayloadVersion + 1 + + + +`)) + // enrollmentProfileMobileconfigTemplate is the template Fleet uses to assemble a .mobileconfig enrollment profile to serve to devices. // // During a profile replacement, the system updates payloads with the same PayloadIdentifier and // PayloadUUID in the old and new profiles. -var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Parse(` +var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Funcs(funcMap).Parse(` @@ -675,7 +959,7 @@ var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Parse Key Type RSA Challenge - {{ .SCEPChallenge }} + {{ .SCEPChallenge | xml }} Key Usage 5 Keysize @@ -726,11 +1010,11 @@ var enrollmentProfileMobileconfigTemplate = template.Must(template.New("").Parse PayloadDisplayName - {{ .Organization }} enrollment + {{ .Organization | xml }} enrollment PayloadIdentifier ` + FleetPayloadIdentifier + ` PayloadOrganization - {{ .Organization }} + {{ .Organization | xml }} PayloadScope System PayloadType @@ -752,13 +1036,8 @@ func GenerateEnrollmentProfileMobileconfig(orgName, fleetURL, scepChallenge, top return nil, fmt.Errorf("resolve Apple MDM url: %w", err) } - var escaped strings.Builder - if err := xml.EscapeText(&escaped, []byte(scepChallenge)); err != nil { - return nil, fmt.Errorf("escape SCEP challenge for XML: %w", err) - } - var buf bytes.Buffer - if err := enrollmentProfileMobileconfigTemplate.Execute(&buf, struct { + if err := enrollmentProfileMobileconfigTemplate.Funcs(funcMap).Execute(&buf, struct { Organization string SCEPURL string SCEPChallenge string @@ -767,7 +1046,7 @@ func GenerateEnrollmentProfileMobileconfig(orgName, fleetURL, scepChallenge, top }{ Organization: orgName, SCEPURL: scepURL, - SCEPChallenge: escaped.String(), + SCEPChallenge: scepChallenge, Topic: topic, ServerURL: serverURL, }); err != nil { @@ -840,3 +1119,101 @@ func (pb *ProfileBimap) add(wantedProfile, currentProfile *fleet.MDMAppleProfile pb.wantedState[wantedProfile] = currentProfile pb.currentState[currentProfile] = wantedProfile } + +func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMAppleCommander, logger kitlog.Logger) error { + appCfg, err := ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching app config") + } + + if !appCfg.MDM.EnabledAndConfigured { + level.Debug(logger).Log("msg", "apple mdm is not configured, skipping run") + return nil + } + + start := time.Now() + devices, err := ds.ListIOSAndIPadOSToRefetch(ctx, 1*time.Hour) + if err != nil { + return ctxerr.Wrap(ctx, err, "list ios and ipad devices to refetch") + } + if len(devices) == 0 { + return nil + } + logger.Log("msg", "sending commands to refetch", "count", len(devices), "lookup-duration", time.Since(start)) + commandUUID := uuid.NewString() + + hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2*len(devices)) + installedAppsUUIDs := make([]string, 0, len(devices)) + for _, device := range devices { + if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchAppsCommandUUIDPrefix) { + installedAppsUUIDs = append(installedAppsUUIDs, device.UUID) + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: device.HostID, + CommandType: fleet.RefetchAppsCommandUUIDPrefix, + }) + } + } + if len(installedAppsUUIDs) > 0 { + err = commander.InstalledApplicationList(ctx, installedAppsUUIDs, fleet.RefetchAppsCommandUUIDPrefix+commandUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "send InstalledApplicationList commands to ios and ipados devices") + } + } + + // DeviceInformation is last because the refetch response clears the refetch_requested flag + deviceInfoUUIDs := make([]string, 0, len(devices)) + for _, device := range devices { + if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchDeviceCommandUUIDPrefix) { + deviceInfoUUIDs = append(deviceInfoUUIDs, device.UUID) + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: device.HostID, + CommandType: fleet.RefetchDeviceCommandUUIDPrefix, + }) + } + } + if len(deviceInfoUUIDs) > 0 { + if err := commander.DeviceInformation(ctx, deviceInfoUUIDs, fleet.RefetchDeviceCommandUUIDPrefix+commandUUID); err != nil { + return ctxerr.Wrap(ctx, err, "send DeviceInformation commands to ios and ipados devices") + } + } + + // Add commands to the database to track the commands sent + err = ds.AddHostMDMCommands(ctx, hostMDMCommands) + if err != nil { + return ctxerr.Wrap(ctx, err, "add host mdm commands") + } + return nil +} + +func GenerateOTAEnrollmentProfileMobileconfig(orgName, fleetURL, enrollSecret string) ([]byte, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/ota_enrollment") + if err != nil { + return nil, fmt.Errorf("creating path for ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("parsing ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + + var profileBuf bytes.Buffer + tmplArgs := struct { + Organization string + URL string + EnrollSecret string + }{ + Organization: orgName, + URL: enrollURL.String(), + } + + err = mobileconfig.OTAMobileConfigTemplate.Execute(&profileBuf, tmplArgs) + if err != nil { + return nil, fmt.Errorf("executing ota profile template: %w", err) + } + + return profileBuf.Bytes(), nil +} diff --git a/server/mdm/apple/apple_mdm_external_test.go b/server/mdm/apple/apple_mdm_external_test.go index c0a63fd2a8..30287b0a32 100644 --- a/server/mdm/apple/apple_mdm_external_test.go +++ b/server/mdm/apple/apple_mdm_external_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -24,6 +25,7 @@ func TestDEPService_RunAssigner(t *testing.T) { ctx := context.Background() ds := mysql.CreateMySQLDS(t) + const abmTokenOrgName = "test_org" depStorage, err := ds.NewMDMAppleDEPStorage() require.NoError(t, err) @@ -33,10 +35,10 @@ func TestDEPService_RunAssigner(t *testing.T) { t.Cleanup(srv.Close) t.Cleanup(func() { mysql.TruncateTables(t, ds) }) - err = depStorage.StoreConfig(ctx, apple_mdm.DEPName, &nanodep_client.Config{BaseURL: srv.URL}) + err = depStorage.StoreConfig(ctx, abmTokenOrgName, &nanodep_client.Config{BaseURL: srv.URL}) require.NoError(t, err) - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, abmTokenOrgName) logger := log.NewNopLogger() return apple_mdm.NewDEPService(ds, depStorage, logger) @@ -52,7 +54,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}) require.NoError(t, err) @@ -76,7 +78,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned for no-team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil) + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -84,7 +86,12 @@ func TestDEPService_RunAssigner(t *testing.T) { // no team to assign to appCfg, err := ds.AppConfig(ctx) require.NoError(t, err) - require.Empty(t, appCfg.MDM.AppleBMDefaultTeam) + require.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + abmTok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName) + require.NoError(t, err) + require.Nil(t, abmTok.MacOSDefaultTeamID) + require.Nil(t, abmTok.IPadOSDefaultTeamID) + require.Nil(t, abmTok.IOSDefaultTeamID) // no teams, so no team-specific custom setup assistants teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}) @@ -118,7 +125,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}) require.NoError(t, err) @@ -156,7 +163,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned to no-team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil) + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -190,7 +197,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": reqBody, err := io.ReadAll(r.Body) require.NoError(t, err) @@ -234,12 +241,11 @@ func TestDEPService_RunAssigner(t *testing.T) { tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test_team"}) require.NoError(t, err) - appCfg, err := ds.AppConfig(ctx) + // set that team as default assignment for new macOS devices + tok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName) require.NoError(t, err) - - // set that team as default assignment for new devices - appCfg.MDM.AppleBMDefaultTeam = tm.Name - err = ds.SaveAppConfig(ctx, appCfg) + tok.MacOSDefaultTeamID = &tm.ID + err = ds.SaveABMToken(ctx, tok) require.NoError(t, err) // create a custom setup assistant for that team @@ -262,7 +268,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned to the team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID) + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -270,8 +276,11 @@ func TestDEPService_RunAssigner(t *testing.T) { // the team-specific custom profile was registered tmAsst, err = ds.GetMDMAppleSetupAssistant(ctx, tmAsst.TeamID) require.NoError(t, err) - require.Equal(t, "profile456", tmAsst.ProfileUUID) require.False(t, tmAsst.UploadedAt.Before(start)) + profileUUID, modTime, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, abmTokenOrgName) + require.NoError(t, err) + require.Equal(t, "profile456", profileUUID) + require.True(t, tmAsst.UploadedAt.Equal(modTime)) // a couple hosts were created and assigned to the team (except the op_type ignored) hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}) diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go index a03b5030d7..156736ecd4 100644 --- a/server/mdm/apple/apple_mdm_test.go +++ b/server/mdm/apple/apple_mdm_test.go @@ -16,6 +16,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/go-kit/log" + "github.com/groob/plist" "github.com/stretchr/testify/require" ) @@ -67,6 +68,9 @@ func TestDEPService(t *testing.T) { Token: p.Token, Type: p.Type, DEPProfile: p.DEPProfile, + UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{ + UpdateTimestamp: fleet.UpdateTimestamp{UpdatedAt: time.Now()}, + }, } savedProfile = res return res, nil @@ -81,14 +85,14 @@ func TestDEPService(t *testing.T) { } var defaultProfileUUID string - ds.GetMDMAppleDefaultSetupAssistantFunc = func(ctx context.Context, teamID *uint) (profileUUID string, updatedAt time.Time, err error) { + ds.GetMDMAppleDefaultSetupAssistantFunc = func(ctx context.Context, teamID *uint, orgName string) (profileUUID string, updatedAt time.Time, err error) { if defaultProfileUUID == "" { return "", time.Time{}, nil } return defaultProfileUUID, time.Now(), nil } - ds.SetMDMAppleDefaultSetupAssistantProfileUUIDFunc = func(ctx context.Context, teamID *uint, profileUUID string) error { + ds.SetMDMAppleDefaultSetupAssistantProfileUUIDFunc = func(ctx context.Context, teamID *uint, profileUUID, orgName string) error { require.Nil(t, teamID) defaultProfileUUID = profileUUID return nil @@ -107,12 +111,19 @@ func TestDEPService(t *testing.T) { } depStorage.StoreAssignerProfileFunc = func(ctx context.Context, name string, profileUUID string) error { - require.Equal(t, name, DEPName) require.NotEmpty(t, profileUUID) return nil } - profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil) + ds.GetABMTokenOrgNamesAssociatedWithTeamFunc = func(ctx context.Context, teamID *uint) ([]string, error) { + return []string{"org1"}, nil + } + + ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { + return 0, nil + } + + profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil, "org1") require.NoError(t, err) require.Equal(t, "abcd", profUUID) require.NotZero(t, modTime) @@ -183,6 +194,79 @@ func TestAddEnrollmentRefToFleetURL(t *testing.T) { } } +func TestGenerateEnrollmentProfileMobileconfig(t *testing.T) { + type scepPayload struct { + Challenge string + URL string + } + + type enrollmentPayload struct { + PayloadType string + ServerURL string // used by the enrollment payload + PayloadContent scepPayload // scep contains a nested payload content dict + } + + type enrollmentProfile struct { + PayloadIdentifier string + PayloadContent []enrollmentPayload + } + + tests := []struct { + name string + orgName string + fleetURL string + scepChallenge string + expectError bool + }{ + { + name: "valid input with simple values", + orgName: "Fleet", + fleetURL: "https://example.com", + scepChallenge: "testChallenge", + expectError: false, + }, + { + name: "organization name and enroll secret with special characters", + orgName: `Fleet & Co. "Special" `, + fleetURL: "https://example.com", + scepChallenge: "test/&Challenge", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateEnrollmentProfileMobileconfig(tt.orgName, tt.fleetURL, tt.scepChallenge, "com.foo.bar") + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, result) + + var profile enrollmentProfile + + require.NoError(t, plist.Unmarshal(result, &profile)) + + for _, p := range profile.PayloadContent { + switch p.PayloadType { + case "com.apple.security.scep": + scepURL, err := ResolveAppleSCEPURL(tt.fleetURL) + require.NoError(t, err) + require.Equal(t, scepURL, p.PayloadContent.URL) + require.Equal(t, tt.scepChallenge, p.PayloadContent.Challenge) + case "com.apple.mdm": + mdmURL, err := ResolveAppleMDMURL(tt.fleetURL) + require.NoError(t, err) + require.Contains(t, mdmURL, p.ServerURL) + default: + require.Failf(t, "unrecognized payload type in enrollment profile: %s", p.PayloadType) + } + } + } + }) + } +} + type notFoundError struct{} func (e notFoundError) IsNotFound() bool { return true } diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 68bd90c82e..6a098a5284 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "fmt" "net/http" + "sort" + "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -383,14 +385,15 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [ // Even if we didn't get an error, some of the APNs // responses might have failed, signal that to the caller. - var failed []string + failed := map[string]error{} for uuid, response := range apnsResponses { if response.Err != nil { - failed = append(failed, uuid) + failed[uuid] = response.Err } } + if len(failed) > 0 { - return &APNSDeliveryError{FailedUUIDs: failed, Err: err} + return &APNSDeliveryError{errorsByUUID: failed} } return nil @@ -399,14 +402,38 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [ // APNSDeliveryError records an error and the associated host UUIDs in which it // occurred. type APNSDeliveryError struct { - FailedUUIDs []string - Err error + errorsByUUID map[string]error } func (e *APNSDeliveryError) Error() string { - return fmt.Sprintf("APNS delivery failed with: %s, for UUIDs: %v", e.Err, e.FailedUUIDs) + var uuids []string + for uuid := range e.errorsByUUID { + uuids = append(uuids, uuid) + } + + // sort UUIDs alphabetically for deterministic output + sort.Strings(uuids) + + var errStrings []string + for _, uuid := range uuids { + errStrings = append(errStrings, fmt.Sprintf("UUID: %s, Error: %v", uuid, e.errorsByUUID[uuid])) + } + + return fmt.Sprintf( + "APNS delivery failed with the following errors:\n%s", + strings.Join(errStrings, "\n"), + ) } -func (e *APNSDeliveryError) Unwrap() error { return e.Err } +func (e *APNSDeliveryError) FailedUUIDs() []string { + var uuids []string + for uuid := range e.errorsByUUID { + uuids = append(uuids, uuid) + } + + // sort UUIDs alphabetically for deterministic output + sort.Strings(uuids) + return uuids +} func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway } diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 5978944b52..29138179f4 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -3,7 +3,9 @@ package apple_mdm import ( "context" "crypto/tls" + "errors" "fmt" + "net/http" "os" "testing" @@ -17,8 +19,8 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestMDMAppleCommander(t *testing.T) { @@ -200,3 +202,50 @@ func mobileconfigForTest(name, identifier string) []byte { `, name, identifier, uuid.New().String())) } + +func TestAPNSDeliveryError(t *testing.T) { + tests := []struct { + name string + errorsByUUID map[string]error + expectedError string + expectedFailedUUIDs []string + expectedStatusCode int + }{ + { + name: "single error", + errorsByUUID: map[string]error{ + "uuid1": errors.New("network error"), + }, + expectedError: `APNS delivery failed with the following errors: +UUID: uuid1, Error: network error`, + expectedFailedUUIDs: []string{"uuid1"}, + expectedStatusCode: http.StatusBadGateway, + }, + { + name: "multiple errors, sorted", + errorsByUUID: map[string]error{ + "uuid3": errors.New("timeout error"), + "uuid1": errors.New("network error"), + "uuid2": errors.New("certificate error"), + }, + expectedError: `APNS delivery failed with the following errors: +UUID: uuid1, Error: network error +UUID: uuid2, Error: certificate error +UUID: uuid3, Error: timeout error`, + expectedFailedUUIDs: []string{"uuid1", "uuid2", "uuid3"}, + expectedStatusCode: http.StatusBadGateway, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apnsErr := &APNSDeliveryError{ + errorsByUUID: tt.errorsByUUID, + } + + require.Equal(t, tt.expectedError, apnsErr.Error()) + require.Equal(t, tt.expectedFailedUUIDs, apnsErr.FailedUUIDs()) + require.Equal(t, tt.expectedStatusCode, apnsErr.StatusCode()) + }) + } +} diff --git a/server/mdm/apple/deviceinfo.go b/server/mdm/apple/deviceinfo.go new file mode 100644 index 0000000000..2f53fe3862 --- /dev/null +++ b/server/mdm/apple/deviceinfo.go @@ -0,0 +1,207 @@ +// The contents of this file have been copied and modified pursuant to the following +// license from the original source: +// https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/header/header.go +// +// MIT License +// +// Copyright (c) 2023 Kory Prince +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package apple_mdm + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha1" // nolint:gosec // See comments regarding Apple's Root CA below + "crypto/x509" + _ "embed" + "encoding/base64" + "errors" + "fmt" + "os" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/groob/plist" + "github.com/smallstep/pkcs7" +) + +const DeviceInfoHeader = "x-apple-aspen-deviceinfo" + +// appleRootCert is https://www.apple.com/appleca/AppleIncRootCertificate.cer +// +//go:embed AppleIncRootCertificate.cer +var appleRootCert []byte + +// appleRootCA is Apple's Root CA parsed to an *x509.Certificate +var appleRootCA = newAppleCert(appleRootCert) + +// appleIphoneDeviceCA is the PEM data defined here converted to DER: +// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/profile-service/profile-service.html#//apple_ref/doc/uid/TP40009505-CH2-SW24 +// +//go:embed AppleIphoneDeviceCA.cer +var appleIphoneDeviceCACert []byte + +// appleIphoneDeviceCA is Apple's Iphone Device CA parsed to an *x509.Certificate +var appleIphoneDeviceCA = newAppleCert(appleIphoneDeviceCACert) + +func newAppleCert(crt []byte) *x509.Certificate { + cert, err := x509.ParseCertificate(crt) + if err != nil { + panic(fmt.Errorf("could not parse cert: %w", err)) + } + return cert +} + +// verifyPKCS7SHA1RSA performs a manual SHA1withRSA verification, since it's deprecated in Go 1.18. +// If verifyChain is true, the signer certificate and its chain of certificates is verified against Apple's Root CA. +// Also note that the certificate validity time window of the signing cert is not checked, since the cert is expired. +// This follows guidance from Apple on the expired certificate. +func verifyPKCS7SHA1RSA(p7 *pkcs7.PKCS7, verifyChain bool) error { + if len(p7.Signers) == 0 { + return errors.New("not signed") + } + + // get signing cert + issuer := p7.Signers[0].IssuerAndSerialNumber + var signer *x509.Certificate + for _, cert := range p7.Certificates { + if bytes.Equal(cert.RawIssuer, issuer.IssuerName.FullBytes) && cert.SerialNumber.Cmp(issuer.SerialNumber) == 0 { + signer = cert + } + } + + // get sha1 hash of content + hashed := sha1.Sum(p7.Content) // nolint:gosec + + // verify content signature + signature := p7.Signers[0].EncryptedDigest + if err := rsa.VerifyPKCS1v15(signer.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], signature); err != nil { + return fmt.Errorf("signature could not be verified: %w", err) + } + + if !verifyChain { + return nil + } + + // verify chain from signer to root + cert := signer +outer: + for { + // check if cert is signed by root + if bytes.Equal(cert.RawIssuer, appleRootCA.RawSubject) { + hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec + // check signature + if err := rsa.VerifyPKCS1v15(appleRootCA.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { + return fmt.Errorf("could not verify root CA signature: %w", err) + } + return nil + } + for _, c := range p7.Certificates { + if cert == c { + continue + } + // check if cert is signed by intermediate cert in chain + if bytes.Equal(cert.RawIssuer, c.RawSubject) { + // check signature + hashed := sha1.Sum(cert.RawTBSCertificate) // nolint:gosec + if err := rsa.VerifyPKCS1v15(c.PublicKey.(*rsa.PublicKey), crypto.SHA1, hashed[:], cert.Signature); err != nil { + return fmt.Errorf("could not verify chained certificate signature: %w", err) + } + cert = c + continue outer + } + } + return errors.New("certificate root not found") + } +} + +// ParseDeviceinfo attempts to parse the provided string, assuming it to be the base64-encoded value +// of an x-apple-aspen-deviceinfo header. If successful, it returns the parsed *fleet.MDMAppleMachineInfo. If the +// verify parameter is specified as true, the signature is also verified against Apple's Root CA and +// an error will be returned if the signature is invalid. +// +// Warning: The information in this header, despite being signed by Apple PKI, shouldn't be trusted +// for device attestation or other security purposes. See the related [documentation] and referenced +// [article] for more information. +// +// [documentation]: https://github.com/korylprince/dep-webview-oidc/blob/2dd846a54fed04c16dd227b8c6c31665b4d0ebd8/docs/Architecture.md#x-apple-aspen-deviceinfo-header +// [article]: https://duo.com/labs/research/mdm-me-maybe +func ParseDeviceinfo(b64 string, verify bool) (*fleet.MDMAppleMachineInfo, error) { + buf, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("could not decode base64: %w", err) + } + + p7, err := pkcs7.Parse(buf) + if err != nil { + return nil, fmt.Errorf("could not decode pkcs7: %w", err) + } + + // verify signature and certificate chain + if verify { + if err = verifyPKCS7SHA1RSA(p7, verify); err != nil { + return nil, fmt.Errorf("could not verify signature: %w", err) + } + } + + info := new(fleet.MDMAppleMachineInfo) + if err = plist.Unmarshal(p7.Content, info); err != nil { + return nil, fmt.Errorf("could not decode plist: %w", err) + } + + return info, nil +} + +// VerifyFromAppleIphoneDeviceCA verifies a certificate was signed by Apple's iPhone Device CA. +// Manually verify the certificate since Go has deprecated verifying SHA1WithRSA x509 certificates. +// +// NOTE: most of this code was taken from micromdm. +func VerifyFromAppleIphoneDeviceCA(c *x509.Certificate) error { + if os.Getenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY") == "1" { + return nil + } + + var hashType crypto.Hash + + switch c.SignatureAlgorithm { + case x509.SHA1WithRSA: + hashType = crypto.SHA1 + case x509.SHA256WithRSA: + hashType = crypto.SHA256 + default: + return fmt.Errorf("%w: %s", x509.ErrUnsupportedAlgorithm, c.SignatureAlgorithm) + } + + hasher := hashType.New() + hasher.Write(c.RawTBSCertificate) + hashed := hasher.Sum(nil) + + key, ok := appleIphoneDeviceCA.PublicKey.(*rsa.PublicKey) + if !ok { + panic("appleIphoneDeviceCA: invalid key type") + } + + if err := rsa.VerifyPKCS1v15(key, hashType, hashed, c.Signature); err != nil { + return fmt.Errorf("verifying signature: %w", err) + } + + return nil +} diff --git a/server/mdm/apple/gdmf/api.go b/server/mdm/apple/gdmf/api.go new file mode 100644 index 0000000000..ee8c671814 --- /dev/null +++ b/server/mdm/apple/gdmf/api.go @@ -0,0 +1,198 @@ +package gdmf + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/cenkalti/backoff" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" +) + +const baseURL = "https://gdmf.apple.com/v2/pmv" + +// Asset represents the metadata for an asset in the Apple Software Lookup Service[1][2]. +// Example: +// +// { +// "ProductVersion": "14.6.1", +// "Build": "23G93", +// "PostingDate": "2024-08-07", +// "ExpirationDate": "2024-11-11", +// "SupportedDevices": [ +// "J132AP", +// "VMA2MACOSAP", +// "VMM-x86_64" +// ] +// } +// +// [1]: http://gdmf.apple.com/v2/pmv +// [2]: +// https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web +type Asset struct { + ProductVersion string `json:"ProductVersion"` + Build string `json:"Build"` + PostingDate string `json:"PostingDate"` + ExpirationDate string `json:"ExpirationDate"` + SupportedDevices []string `json:"SupportedDevices"` +} + +// AssetSets represents the metadata for a set of assets in the Apple Software Lookup Service[1][2]. +// [1]: http://gdmf.apple.com/v2/pmv +// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web +type AssetSets struct { + IOS []Asset `json:"iOS"` + MacOS []Asset `json:"macOS"` + // VisionOS []Asset `json:"visionOS"` // Fleet doesn't support visionOS yet + // XROS []Asset `json:"xrOS"` // Fleet doesn't support xrOS yet +} + +// APIResponse represents the response from the Apple Software Lookup Service[1][2]. +// [1]: http://gdmf.apple.com/v2/pmv +// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web +type APIResponse struct { + PublicAssetSets AssetSets `json:"PublicAssetSets"` + AssetSets AssetSets `json:"AssetSets"` + // PublicRapidSecurityResponses interface{} `json:"PublicRapidSecurityResponses"` // Fleet doesn't support PublicRapidSecurityResponses yet +} + +// GetLatestOSVersion returns the latest OS version for the given device. The device is matched +// against the Apple Software Update Lookup Service[1][2] to find the latest version. If no matching +// asset is found, an error is returned. +// [1]: http://gdmf.apple.com/v2/pmv +// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web +func GetLatestOSVersion(device fleet.MDMAppleMachineInfo) (*Asset, error) { + r, err := GetAssetMetadata() + if err != nil { + return nil, fmt.Errorf("retrieving asset metadata: %w", err) + } + + assetSet := r.PublicAssetSets.MacOS // default to public asset set; note that if the device is not macOS, iPhone, or iPad, we'll fail to match the supported device and return an error below + if strings.HasPrefix(device.Product, "iPhone") || + strings.HasPrefix(device.Product, "iPad") || + strings.HasPrefix(device.SoftwareUpdateDeviceID, "iPhone") || + strings.HasPrefix(device.SoftwareUpdateDeviceID, "iPad") { + assetSet = r.PublicAssetSets.IOS + } + latestIdx := -1 + for i, s := range assetSet { + for _, d := range s.SupportedDevices { + if d == device.Product || d == device.SoftwareUpdateDeviceID { + if latestIdx == -1 { + latestIdx = i // first match found, update the index + continue + } + if apple_mdm.CompareVersions(assetSet[latestIdx].ProductVersion, s.ProductVersion) < 0 { + latestIdx = i // found a later version, update the index + } + } + } + } + if latestIdx == -1 { + return nil, fmt.Errorf("no matching asset found for device %s", device.Product) + } + return &assetSet[latestIdx], nil +} + +// client is a package-level client (similar to http.DefaultClient) so it can +// be reused instead of created as needed, as the internal Transport typically +// has internal state (cached connections, etc) and it's safe for concurrent +// use. +var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) + +// GetAssetMetadata retrieves the asset metadata from the Apple Software Lookup Service[1][2]. +// [1]: http://gdmf.apple.com/v2/pmv +// [2]: https://support.apple.com/guide/deployment/use-mdm-to-deploy-software-updates-depafd2fad80/web +func GetAssetMetadata() (*APIResponse, error) { + baseURL := getBaseURL() + reqURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parsing base URL: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request to Apple endpoint: %w", err) + } + req.Header.Set("User-Agent", "fleet-device-management") + + resp, err := doWithRetry(req) + if err != nil { + return nil, fmt.Errorf("retrieving asset metadata: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body from Apple endpoint: %w", err) + } + var dest APIResponse + if err := json.Unmarshal(body, &dest); err != nil { + return nil, fmt.Errorf("decoding response data from Apple endpoint: %w", err) + } + + return &dest, nil +} + +func doWithRetry(req *http.Request) (*http.Response, error) { + const ( + maxRetries = 3 + retryBackoff = 1 * time.Second + maxWaitForRetryAfter = 10 * time.Second + ) + var resp *http.Response + var err error + op := func() error { + resp, err = client.Do(req) + if err != nil { + return err + } + + defer func() { + if resp != nil && resp.StatusCode >= http.StatusBadRequest { + // consume and close the body for retried requests to prevent resource leaks + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + } + }() + + if resp.StatusCode == http.StatusTooManyRequests { + // handle 429 rate-limits + rawAfter := resp.Header.Get("Retry-After") + afterSecs, err := strconv.ParseInt(rawAfter, 10, 0) + if err == nil && (time.Duration(afterSecs)*time.Second) < maxWaitForRetryAfter { + // the retry-after duration is reasonable, wait for it and return a + // retryable error so that we try again. + time.Sleep(time.Duration(afterSecs) * time.Second) + return errors.New("retry after requested delay") + } + } + if resp.StatusCode >= http.StatusBadRequest { + // 400+ status can be worth retrying + return fmt.Errorf("calling gdmf endpoint failed with status %d", resp.StatusCode) + } + return nil + } + + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewConstantBackOff(retryBackoff), uint64(maxRetries))); err != nil { + return nil, err + } + + return resp, err +} + +func getBaseURL() string { + devURL := os.Getenv("FLEET_DEV_GDMF_URL") + if devURL != "" { + return devURL + } + return baseURL +} diff --git a/server/mdm/apple/gdmf/api_test.go b/server/mdm/apple/gdmf/api_test.go new file mode 100644 index 0000000000..14bc7d8061 --- /dev/null +++ b/server/mdm/apple/gdmf/api_test.go @@ -0,0 +1,247 @@ +package gdmf + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestGetLatest(t *testing.T) { + // test GetLatestOSVersion using a mock server that returns a known response + // and ensure the response is parsed correctly + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // load the test data from the file + b, err := os.ReadFile("./testdata/gdmf.json") + require.NoError(t, err) + _, err = w.Write(b) + require.NoError(t, err) + })) + defer srv.Close() + t.Setenv("FLEET_DEV_GDMF_URL", srv.URL) + + // test the function + d := fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + OSVersion: "14.4.1", + Product: "Mac15,7", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "J516sAP", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + } + + latestMacOSVersion := "14.6.1" + latestMacOSBuild := "23G93" + latestIOSVersion := "17.6.1" + latestIOSBuild := "21G93" + + resp, err := GetLatestOSVersion(d) + require.NoError(t, err) + require.Equal(t, latestMacOSVersion, resp.ProductVersion) + require.Equal(t, latestMacOSBuild, resp.Build) + + // NOTE: GetLatestOSVersion does not depend on the value of MDMCanRequestSoftwareUpdate. It is + // expected that the caller has already verified this value before calling GetLatestOSVersion. + + tests := []struct { + name string + machineInfo fleet.MDMAppleMachineInfo + expectedVersion string + expectedBuild string + expectError bool + }{ + { + name: "macOS matching software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "14.4.1", + Product: "Mac15,7", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "J516sAP", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + }, + expectedVersion: latestMacOSVersion, + expectedBuild: latestMacOSBuild, + expectError: false, + }, + { + // macOS generally relies on the SoftwareUpdateDeviceID field and not the Product field + name: "macOS non-matching software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "14.4.1", + Product: "Mac15,7", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "INVALID", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + }, + expectedVersion: latestMacOSVersion, + expectedBuild: latestMacOSBuild, + expectError: true, + }, + { + // this should never happen in practice, but by default we still check macOS assets to + // match the software update device ID + name: "non-matching product but matching software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "14.4.1", + Product: "INVALID", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "J516sAP", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + }, + expectedVersion: latestMacOSVersion, + expectedBuild: latestMacOSBuild, + expectError: false, + }, + { + name: "non-matching product and software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "14.4.1", + Product: "INVALID", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "INVALID", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + }, + expectedVersion: "", + expectedBuild: "", + expectError: true, + }, + { + // missing other fields is not an error, this function always returns the latest + // version and only depends on the Product and SoftwareUpdateDeviceID fields + name: "missing other fields", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "", + Product: "Mac15,7", + SoftwareUpdateDeviceID: "J516sAP", + }, + expectedVersion: latestMacOSVersion, + expectedBuild: latestMacOSBuild, + expectError: false, + }, + { + name: "iphone matching product and software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "17.5.1", + Product: "iPhone14,6", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "iPhone14,6", + SupplementalBuildVersion: "21F90", + UDID: uuid.New().String(), + Version: "21F90", + }, + expectedVersion: latestIOSVersion, + expectedBuild: latestIOSBuild, + expectError: false, + }, + { + // iOS generally relies on the Product field and not the SoftwareUpdateDeviceID field so + // this won't error even though the SoftwareUpdateDeviceID is invalid + name: "iphone non-matching software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "17.5.1", + Product: "iPhone14,6", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "INVALID", + SupplementalBuildVersion: "21F90", + UDID: uuid.New().String(), + Version: "21F90", + }, + expectedVersion: latestIOSVersion, + expectedBuild: latestIOSBuild, + expectError: false, + }, + { + // this should never happen in practice, but we'll still try to match iOS assets if the + // software update device ID starts with "iPhone" or "iPad" + name: "missing product but valid iphone software update device ID", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "17.5.1", + Product: "", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "iPhone14,6", + SupplementalBuildVersion: "21F90", + UDID: uuid.New().String(), + Version: "21F90", + }, + expectedVersion: latestIOSVersion, + expectedBuild: latestIOSBuild, + expectError: false, + }, + { + // we don't support other Apple products yet, so this should always error + // because we we default to the macOS asset set and we won't find a matching asset there + name: "unsupported product", + machineInfo: fleet.MDMAppleMachineInfo{ + OSVersion: "8.8.1", + Product: "Watch3,1", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "Watch3,1", + SupplementalBuildVersion: "19U512", + UDID: uuid.New().String(), + Version: "19U512", + }, + expectedVersion: "", + expectedBuild: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := GetLatestOSVersion(tt.machineInfo) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedVersion, resp.ProductVersion) + require.Equal(t, tt.expectedBuild, resp.Build) + } + }) + } +} + +func TestRetries(t *testing.T) { + retryCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + retryCount++ + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`{"error": "bad request"}`)) + require.NoError(t, err) + })) + os.Setenv("FLEET_DEV_GDMF_URL", srv.URL) + t.Cleanup(func() { + srv.Close() + os.Unsetenv("FLEET_DEV_GDMF_URL") + }) + + latest, err := GetLatestOSVersion(fleet.MDMAppleMachineInfo{ + OSVersion: "14.4.1", + Product: "Mac15,7", + Serial: "TESTSERIAL", + SoftwareUpdateDeviceID: "J516sAP", + SupplementalBuildVersion: "23E224", + UDID: uuid.New().String(), + Version: "23E224", + }) + + require.Error(t, err) + require.ErrorContains(t, err, "calling gdmf endpoint failed with status 400") + require.Nil(t, latest) + require.Equal(t, 4, retryCount) +} diff --git a/server/mdm/apple/gdmf/testdata/gdmf.json b/server/mdm/apple/gdmf/testdata/gdmf.json new file mode 100644 index 0000000000..7993d37b4d --- /dev/null +++ b/server/mdm/apple/gdmf/testdata/gdmf.json @@ -0,0 +1,3325 @@ +{ + "PublicAssetSets": { + "iOS": [ + { + "ProductVersion": "6.3", + "Build": "17U6208", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["Watch2,3", "Watch2,4", "Watch2,6", "Watch2,7"] + }, + { + "ProductVersion": "1.3", + "Build": "21O771", + "PostingDate": "2024-08-05", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "10.6", + "Build": "21U6577", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "1.3", + "Build": "21O6771", + "PostingDate": "2024-08-05", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "17.6", + "Build": "21M71", + "PostingDate": "2024-08-05", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21M71", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "8.8.1", + "Build": "19U6512", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "16.7.10", + "Build": "20H6350", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "16.7.2", + "Build": "20H115", + "PostingDate": "2023-10-25", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3" + ] + }, + { + "ProductVersion": "17.6.1", + "Build": "21G93", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "16.7.2", + "Build": "20H6115", + "PostingDate": "2023-10-25", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3" + ] + }, + { + "ProductVersion": "16.7.10", + "Build": "20H350", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "9.6.3", + "Build": "20U502", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "6.3", + "Build": "17U208", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["Watch2,3", "Watch2,4", "Watch2,6", "Watch2,7"] + }, + { + "ProductVersion": "8.8.1", + "Build": "19U512", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21M6071", + "PostingDate": "2024-08-05", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21M6071", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "17.6.1", + "Build": "21G6093", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "12.5.7", + "Build": "16H81", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad4,1", + "iPad4,2", + "iPad4,3", + "iPad4,4", + "iPad4,5", + "iPad4,6", + "iPad4,7", + "iPad4,8", + "iPad4,9", + "iPhone6,1", + "iPhone6,2", + "iPhone7,1", + "iPhone7,2", + "iPod7,1" + ] + }, + { + "ProductVersion": "12.5.7", + "Build": "16H6081", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad4,1", + "iPad4,2", + "iPad4,3", + "iPad4,4", + "iPad4,5", + "iPad4,6", + "iPad4,7", + "iPad4,8", + "iPad4,9", + "iPhone6,1", + "iPhone6,2", + "iPhone7,1", + "iPhone7,2", + "iPod7,1" + ] + }, + { + "ProductVersion": "15.8.3", + "Build": "19H386", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "10.6", + "Build": "21U577", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "5.3.9", + "Build": "16U693", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch2,3", + "Watch2,4", + "Watch2,6", + "Watch2,7", + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4" + ] + }, + { + "ProductVersion": "15.8.3", + "Build": "19H6386", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "9.6.3", + "Build": "20U6502", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + } + ], + "visionOS": [ + { + "ProductVersion": "1.1.2", + "Build": "21O231", + "PostingDate": "2024-04-12", + "ExpirationDate": "2024-09-08", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "1.1.2", + "Build": "21O6231", + "PostingDate": "2024-04-12", + "ExpirationDate": "2024-09-08", + "SupportedDevices": ["RealityDevice14,1"] + } + ], + "xrOS": [ + { + "ProductVersion": "1.0.3", + "Build": "21N333", + "PostingDate": "2024-02-12", + "ExpirationDate": "2024-06-05", + "SupportedDevices": ["RealityDevice14,1"] + } + ], + "macOS": [ + { + "ProductVersion": "14.6.1", + "Build": "23G93", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J514cAP", + "J514mAP", + "J514sAP", + "J516cAP", + "J516mAP", + "J516sAP", + "J613AP", + "J615AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-63001698E7A34814", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "13.6.9", + "Build": "22G830", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-EE2EBD4B90B839A8", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "12.7.6", + "Build": "21H1320", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J456AP", + "J457AP", + "J493AP", + "J680AP", + "J780AP", + "Mac-06F11F11946D27C5", + "Mac-06F11FD93F0323C5", + "Mac-1E7E29AD0135F9BC", + "Mac-35C5E08120C7EEAF", + "Mac-473D31EABEB93F9B", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-65CE76090165799A", + "Mac-66E35819EE2D0D05", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-937CB26E2E02BB01", + "Mac-9AE82516C7C6B903", + "Mac-9F18E312C5C2BF0B", + "Mac-A369DDC4E67F1C45", + "Mac-A5C67F76ED83108C", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-B809C3757DA9BB8D", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-DB15BD556843C820", + "Mac-E43C1C25D4880AD6", + "Mac-EE2EBD4B90B839A8", + "Mac-F60DEB81FF30ACF6", + "Mac-FFE5EF870D7BA81A", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "11.7.10", + "Build": "20G1427", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J185AP", + "J185FAP", + "J213AP", + "J214AP", + "J214KAP", + "J215AP", + "J223AP", + "J230AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J456AP", + "J457AP", + "J680AP", + "J780AP", + "Mac-06F11F11946D27C5", + "Mac-06F11FD93F0323C5", + "Mac-0CFF9C7C2B63DF8D", + "Mac-112818653D3AABFC", + "Mac-112B0A653D3AAB9C", + "Mac-189A3D4F975D5FFC", + "Mac-1E7E29AD0135F9BC", + "Mac-226CB3C6A851A671", + "Mac-27AD2F918AE68F61", + "Mac-2BD1B31983FE1663", + "Mac-35C1E88140C3E6CF", + "Mac-35C5E08120C7EEAF", + "Mac-36B6B6DA9CFCD881", + "Mac-3CBD00234E554E41", + "Mac-42FD25EABCABB274", + "Mac-473D31EABEB93F9B", + "Mac-4B682C642B45593E", + "Mac-50619A408DB004DA", + "Mac-53FDB3D8DB8CA971", + "Mac-551B86E5744E2388", + "Mac-5A49A77366F81C72", + "Mac-5F9802EFE386AA28", + "Mac-63001698E7A34814", + "Mac-65CE76090165799A", + "Mac-66E35819EE2D0D05", + "Mac-747B1AEFF11738BE", + "Mac-77F17D7DA9285301", + "Mac-7BA5B2D9E42DDD94", + "Mac-7BA5B2DFE22DDD8C", + "Mac-7DF21CB3ED6977E5", + "Mac-81E3E92DD6088272", + "Mac-827FAC58A8FDFA22", + "Mac-827FB448E656EC26", + "Mac-90BE64C3CB5A9AEB", + "Mac-937A206F2EE63C01", + "Mac-937CB26E2E02BB01", + "Mac-9394BDF4BF862EE7", + "Mac-9AE82516C7C6B903", + "Mac-9F18E312C5C2BF0B", + "Mac-A369DDC4E67F1C45", + "Mac-A5C67F76ED83108C", + "Mac-A61BADE1FDAD7B05", + "Mac-AA95B1DDAB278B95", + "Mac-AF89B6D9451A490B", + "Mac-B4831CEBD52A0C4C", + "Mac-B809C3757DA9BB8D", + "Mac-BE088AF8C5EB4FA2", + "Mac-BE0E8AC46FE800CC", + "Mac-C6F71043CEAA02A6", + "Mac-CAD6701F7CEA0921", + "Mac-CF21D135A7D34AA6", + "Mac-CFF7D910A743CAAF", + "Mac-DB15BD556843C820", + "Mac-E1008331FDC96864", + "Mac-E43C1C25D4880AD6", + "Mac-E7203C0F68AA0004", + "Mac-EE2EBD4B90B839A8", + "Mac-F305150B0C7DEEEF", + "Mac-F60DEB81FF30ACF6", + "Mac-FA842E06C61E91C5", + "Mac-FFE5EF870D7BA81A", + "VMM-x86_64", + "X589AMLUAP", + "X589ICLYAP", + "X86LEGACYAP" + ] + } + ] + }, + "AssetSets": { + "iOS": [ + { + "ProductVersion": "6.3", + "Build": "17U6208", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["Watch2,3", "Watch2,4", "Watch2,6", "Watch2,7"] + }, + { + "ProductVersion": "17.5", + "Build": "21L569", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21L569", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-08-19", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "10.6", + "Build": "21U6577", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21F6079", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-08-18", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21G6080", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21M71", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2", + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "16.7.10", + "Build": "20H6350", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "16.7.2", + "Build": "20H115", + "PostingDate": "2023-10-25", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3" + ] + }, + { + "ProductVersion": "10.5", + "Build": "21T576", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21F6084", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-08-18", + "SupportedDevices": [ + "iPad14,10", + "iPad14,11", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6" + ] + }, + { + "ProductVersion": "16.7.2", + "Build": "20H6115", + "PostingDate": "2023-10-25", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3" + ] + }, + { + "ProductVersion": "16.7.10", + "Build": "20H350", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "10.5", + "Build": "21T6576", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "6.3", + "Build": "17U208", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["Watch2,3", "Watch2,4", "Watch2,6", "Watch2,7"] + }, + { + "ProductVersion": "8.8.1", + "Build": "19U512", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "17.6.1", + "Build": "21G6093", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "15.8.2", + "Build": "19H384", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "1.2", + "Build": "21O589", + "PostingDate": "2024-06-10", + "ExpirationDate": "2024-10-27", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "17.5.1", + "Build": "21F6090", + "PostingDate": "2024-05-20", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "12.5.7", + "Build": "16H81", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad4,1", + "iPad4,2", + "iPad4,3", + "iPad4,4", + "iPad4,5", + "iPad4,6", + "iPad4,7", + "iPad4,8", + "iPad4,9", + "iPhone6,1", + "iPhone6,2", + "iPhone7,1", + "iPhone7,2", + "iPod7,1" + ] + }, + { + "ProductVersion": "12.5.7", + "Build": "16H6081", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad4,1", + "iPad4,2", + "iPad4,3", + "iPad4,4", + "iPad4,5", + "iPad4,6", + "iPad4,7", + "iPad4,8", + "iPad4,9", + "iPhone6,1", + "iPhone6,2", + "iPhone7,1", + "iPhone7,2", + "iPod7,1" + ] + }, + { + "ProductVersion": "10.6", + "Build": "21U577", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9", + "Watch7,1", + "Watch7,2", + "Watch7,3", + "Watch7,4", + "Watch7,5" + ] + }, + { + "ProductVersion": "15.8.3", + "Build": "19H386", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "16.7.8", + "Build": "20H6343", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21G80", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "17.5.1", + "Build": "21L6580", + "PostingDate": "2024-05-21", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "16.7.9", + "Build": "20H6348", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "5.3.9", + "Build": "16U693", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch2,3", + "Watch2,4", + "Watch2,6", + "Watch2,7", + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4" + ] + }, + { + "ProductVersion": "17.5.1", + "Build": "21L580", + "PostingDate": "2024-05-21", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "1.3", + "Build": "21O771", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "1.3", + "Build": "21O6771", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "8.8.1", + "Build": "19U6512", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch3,1", + "Watch3,2", + "Watch3,3", + "Watch3,4", + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "17.6.1", + "Build": "21G93", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21F79", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-08-18", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "9.6.3", + "Build": "20U502", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + }, + { + "ProductVersion": "17.5.1", + "Build": "21F90", + "PostingDate": "2024-05-20", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad11,1", + "iPad11,2", + "iPad11,3", + "iPad11,4", + "iPad11,6", + "iPad11,7", + "iPad12,1", + "iPad12,2", + "iPad13,1", + "iPad13,10", + "iPad13,11", + "iPad13,16", + "iPad13,17", + "iPad13,18", + "iPad13,19", + "iPad13,2", + "iPad13,4", + "iPad13,5", + "iPad13,6", + "iPad13,7", + "iPad13,8", + "iPad13,9", + "iPad14,1", + "iPad14,10", + "iPad14,11", + "iPad14,2", + "iPad14,3", + "iPad14,4", + "iPad14,5", + "iPad14,6", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6", + "iPad7,1", + "iPad7,11", + "iPad7,12", + "iPad7,2", + "iPad7,3", + "iPad7,4", + "iPad7,5", + "iPad7,6", + "iPad8,1", + "iPad8,10", + "iPad8,11", + "iPad8,12", + "iPad8,2", + "iPad8,3", + "iPad8,4", + "iPad8,5", + "iPad8,6", + "iPad8,7", + "iPad8,8", + "iPad8,9", + "iPhone11,2", + "iPhone11,4", + "iPhone11,6", + "iPhone11,8", + "iPhone12,1", + "iPhone12,3", + "iPhone12,5", + "iPhone12,8", + "iPhone13,1", + "iPhone13,2", + "iPhone13,3", + "iPhone13,4", + "iPhone14,2", + "iPhone14,3", + "iPhone14,4", + "iPhone14,5", + "iPhone14,6", + "iPhone14,7", + "iPhone14,8", + "iPhone15,2", + "iPhone15,3", + "iPhone15,4", + "iPhone15,5", + "iPhone16,1", + "iPhone16,2" + ] + }, + { + "ProductVersion": "17.6", + "Build": "21M6071", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2", + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "16.7.8", + "Build": "20H343", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21L6569", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "AudioAccessory1,1", + "AudioAccessory5,1", + "AudioAccessory6,1" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21L6569", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-08-19", + "SupportedDevices": [ + "AppleTV11,1", + "AppleTV14,1", + "AppleTV5,3", + "AppleTV6,2" + ] + }, + { + "ProductVersion": "17.5", + "Build": "21F84", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-08-18", + "SupportedDevices": [ + "iPad14,10", + "iPad14,11", + "iPad14,8", + "iPad14,9", + "iPad16,3", + "iPad16,4", + "iPad16,5", + "iPad16,6" + ] + }, + { + "ProductVersion": "16.7.9", + "Build": "20H348", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "iPad6,11", + "iPad6,12", + "iPad6,3", + "iPad6,4", + "iPad6,7", + "iPad6,8", + "iPhone10,1", + "iPhone10,2", + "iPhone10,3", + "iPhone10,4", + "iPhone10,5", + "iPhone10,6" + ] + }, + { + "ProductVersion": "1.2", + "Build": "21O6589", + "PostingDate": "2024-06-10", + "ExpirationDate": "2024-10-27", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "15.8.2", + "Build": "19H6384", + "PostingDate": "2024-05-14", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "15.8.3", + "Build": "19H6386", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "iPad5,1", + "iPad5,2", + "iPad5,3", + "iPad5,4", + "iPhone8,1", + "iPhone8,2", + "iPhone8,4", + "iPhone9,1", + "iPhone9,2", + "iPhone9,3", + "iPhone9,4", + "iPod9,1" + ] + }, + { + "ProductVersion": "9.6.3", + "Build": "20U6502", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "Watch4,1", + "Watch4,2", + "Watch4,3", + "Watch4,4", + "Watch5,1", + "Watch5,10", + "Watch5,11", + "Watch5,12", + "Watch5,2", + "Watch5,3", + "Watch5,4", + "Watch5,9", + "Watch6,1", + "Watch6,10", + "Watch6,11", + "Watch6,12", + "Watch6,13", + "Watch6,14", + "Watch6,15", + "Watch6,16", + "Watch6,17", + "Watch6,18", + "Watch6,2", + "Watch6,3", + "Watch6,4", + "Watch6,6", + "Watch6,7", + "Watch6,8", + "Watch6,9" + ] + } + ], + "visionOS": [ + { + "ProductVersion": "1.1.2", + "Build": "21O231", + "PostingDate": "2024-04-12", + "ExpirationDate": "2024-09-08", + "SupportedDevices": ["RealityDevice14,1"] + }, + { + "ProductVersion": "1.1.2", + "Build": "21O6231", + "PostingDate": "2024-04-12", + "ExpirationDate": "2024-09-08", + "SupportedDevices": ["RealityDevice14,1"] + } + ], + "xrOS": [ + { + "ProductVersion": "1.0.3", + "Build": "21N333", + "PostingDate": "2024-02-12", + "ExpirationDate": "2024-06-05", + "SupportedDevices": ["RealityDevice14,1"] + } + ], + "macOS": [ + { + "ProductVersion": "14.6.1", + "Build": "23G93", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J514cAP", + "J514mAP", + "J514sAP", + "J516cAP", + "J516mAP", + "J516sAP", + "J613AP", + "J615AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-63001698E7A34814", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "13.6.9", + "Build": "22G830", + "PostingDate": "2024-08-07", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-EE2EBD4B90B839A8", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "14.5", + "Build": "23F79", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J514cAP", + "J514mAP", + "J514sAP", + "J516cAP", + "J516mAP", + "J516sAP", + "J613AP", + "J615AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-63001698E7A34814", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "12.7.5", + "Build": "21H1222", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J456AP", + "J457AP", + "J493AP", + "J680AP", + "J780AP", + "Mac-06F11F11946D27C5", + "Mac-06F11FD93F0323C5", + "Mac-1E7E29AD0135F9BC", + "Mac-35C5E08120C7EEAF", + "Mac-473D31EABEB93F9B", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-65CE76090165799A", + "Mac-66E35819EE2D0D05", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-937CB26E2E02BB01", + "Mac-9AE82516C7C6B903", + "Mac-9F18E312C5C2BF0B", + "Mac-A369DDC4E67F1C45", + "Mac-A5C67F76ED83108C", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-B809C3757DA9BB8D", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-DB15BD556843C820", + "Mac-E43C1C25D4880AD6", + "Mac-EE2EBD4B90B839A8", + "Mac-F60DEB81FF30ACF6", + "Mac-FFE5EF870D7BA81A", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "11.7.10", + "Build": "20G1427", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J185AP", + "J185FAP", + "J213AP", + "J214AP", + "J214KAP", + "J215AP", + "J223AP", + "J230AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J456AP", + "J457AP", + "J680AP", + "J780AP", + "Mac-06F11F11946D27C5", + "Mac-06F11FD93F0323C5", + "Mac-0CFF9C7C2B63DF8D", + "Mac-112818653D3AABFC", + "Mac-112B0A653D3AAB9C", + "Mac-189A3D4F975D5FFC", + "Mac-1E7E29AD0135F9BC", + "Mac-226CB3C6A851A671", + "Mac-27AD2F918AE68F61", + "Mac-2BD1B31983FE1663", + "Mac-35C1E88140C3E6CF", + "Mac-35C5E08120C7EEAF", + "Mac-36B6B6DA9CFCD881", + "Mac-3CBD00234E554E41", + "Mac-42FD25EABCABB274", + "Mac-473D31EABEB93F9B", + "Mac-4B682C642B45593E", + "Mac-50619A408DB004DA", + "Mac-53FDB3D8DB8CA971", + "Mac-551B86E5744E2388", + "Mac-5A49A77366F81C72", + "Mac-5F9802EFE386AA28", + "Mac-63001698E7A34814", + "Mac-65CE76090165799A", + "Mac-66E35819EE2D0D05", + "Mac-747B1AEFF11738BE", + "Mac-77F17D7DA9285301", + "Mac-7BA5B2D9E42DDD94", + "Mac-7BA5B2DFE22DDD8C", + "Mac-7DF21CB3ED6977E5", + "Mac-81E3E92DD6088272", + "Mac-827FAC58A8FDFA22", + "Mac-827FB448E656EC26", + "Mac-90BE64C3CB5A9AEB", + "Mac-937A206F2EE63C01", + "Mac-937CB26E2E02BB01", + "Mac-9394BDF4BF862EE7", + "Mac-9AE82516C7C6B903", + "Mac-9F18E312C5C2BF0B", + "Mac-A369DDC4E67F1C45", + "Mac-A5C67F76ED83108C", + "Mac-A61BADE1FDAD7B05", + "Mac-AA95B1DDAB278B95", + "Mac-AF89B6D9451A490B", + "Mac-B4831CEBD52A0C4C", + "Mac-B809C3757DA9BB8D", + "Mac-BE088AF8C5EB4FA2", + "Mac-BE0E8AC46FE800CC", + "Mac-C6F71043CEAA02A6", + "Mac-CAD6701F7CEA0921", + "Mac-CF21D135A7D34AA6", + "Mac-CFF7D910A743CAAF", + "Mac-DB15BD556843C820", + "Mac-E1008331FDC96864", + "Mac-E43C1C25D4880AD6", + "Mac-E7203C0F68AA0004", + "Mac-EE2EBD4B90B839A8", + "Mac-F305150B0C7DEEEF", + "Mac-F60DEB81FF30ACF6", + "Mac-FA842E06C61E91C5", + "Mac-FFE5EF870D7BA81A", + "VMM-x86_64", + "X589AMLUAP", + "X589ICLYAP", + "X86LEGACYAP" + ] + }, + { + "ProductVersion": "13.6.8", + "Build": "22G820", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-EE2EBD4B90B839A8", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "14.6", + "Build": "23G80", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-05", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J514cAP", + "J514mAP", + "J514sAP", + "J516cAP", + "J516mAP", + "J516sAP", + "J613AP", + "J615AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-63001698E7A34814", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "12.7.6", + "Build": "21H1320", + "PostingDate": "2024-07-29", + "ExpirationDate": "2024-11-11", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J456AP", + "J457AP", + "J493AP", + "J680AP", + "J780AP", + "Mac-06F11F11946D27C5", + "Mac-06F11FD93F0323C5", + "Mac-1E7E29AD0135F9BC", + "Mac-35C5E08120C7EEAF", + "Mac-473D31EABEB93F9B", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-65CE76090165799A", + "Mac-66E35819EE2D0D05", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-937CB26E2E02BB01", + "Mac-9AE82516C7C6B903", + "Mac-9F18E312C5C2BF0B", + "Mac-A369DDC4E67F1C45", + "Mac-A5C67F76ED83108C", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-B809C3757DA9BB8D", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-DB15BD556843C820", + "Mac-E43C1C25D4880AD6", + "Mac-EE2EBD4B90B839A8", + "Mac-F60DEB81FF30ACF6", + "Mac-FFE5EF870D7BA81A", + "VMA2MACOSAP", + "VMM-x86_64" + ] + }, + { + "ProductVersion": "13.6.7", + "Build": "22G720", + "PostingDate": "2024-05-13", + "ExpirationDate": "2024-10-27", + "SupportedDevices": [ + "J132AP", + "J137AP", + "J140AAP", + "J140KAP", + "J152FAP", + "J160AP", + "J174AP", + "J180dAP", + "J185AP", + "J185FAP", + "J213AP", + "J214KAP", + "J215AP", + "J223AP", + "J230KAP", + "J274AP", + "J293AP", + "J313AP", + "J314cAP", + "J314sAP", + "J316cAP", + "J316sAP", + "J375cAP", + "J375dAP", + "J413AP", + "J414cAP", + "J414sAP", + "J415AP", + "J416cAP", + "J416sAP", + "J433AP", + "J434AP", + "J456AP", + "J457AP", + "J473AP", + "J474sAP", + "J475cAP", + "J475dAP", + "J493AP", + "J504AP", + "J680AP", + "J780AP", + "Mac-1E7E29AD0135F9BC", + "Mac-4B682C642B45593E", + "Mac-551B86E5744E2388", + "Mac-63001698E7A34814", + "Mac-77F17D7DA9285301", + "Mac-937A206F2EE63C01", + "Mac-AA95B1DDAB278B95", + "Mac-B4831CEBD52A0C4C", + "Mac-BE088AF8C5EB4FA2", + "Mac-CAD6701F7CEA0921", + "Mac-EE2EBD4B90B839A8", + "VMA2MACOSAP", + "VMM-x86_64" + ] + } + ] + }, + "PublicRapidSecurityResponses": {} +} diff --git a/server/mdm/apple/mobileconfig/mobileconfig.go b/server/mdm/apple/mobileconfig/mobileconfig.go index 31edd44399..690b15c796 100644 --- a/server/mdm/apple/mobileconfig/mobileconfig.go +++ b/server/mdm/apple/mobileconfig/mobileconfig.go @@ -2,6 +2,7 @@ package mobileconfig import ( "bytes" + "encoding/xml" "errors" "fmt" "strings" @@ -264,3 +265,17 @@ var ( ErrEmptyPayloadContent = errors.New("empty PayloadContent") ErrEncryptedPayloadContent = errors.New("encrypted PayloadContent") ) + +// XMLEscapeString returns the escaped XML equivalent of the plain text data s. +func XMLEscapeString(s string) (string, error) { + // avoid allocation if we can. + if !strings.ContainsAny(s, "'\"&<>\t\n\r") { + return s, nil + } + var b strings.Builder + if err := xml.EscapeText(&b, []byte(s)); err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/server/mdm/apple/mobileconfig/mobileconfig_test.go b/server/mdm/apple/mobileconfig/mobileconfig_test.go new file mode 100644 index 0000000000..793503a438 --- /dev/null +++ b/server/mdm/apple/mobileconfig/mobileconfig_test.go @@ -0,0 +1,36 @@ +package mobileconfig + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestXMLEscapeString(t *testing.T) { + tests := []struct { + input string + expected string + }{ + // characters that should be escaped + {"hello & world", "hello & world"}, + {"this is a ", "this is a <test>"}, + {"\"quotes\" and 'single quotes'", ""quotes" and 'single quotes'"}, + {"special chars: \t\n\r", "special chars: "}, + // no special characters + {"plain string", "plain string"}, + // string that already contains escaped characters + {"already <escaped>", "already &lt;escaped&gt;"}, + // empty string + {"", ""}, + // multiple special characters + {"A&BD\"'E\tF\nG\r", "A&B<C>D"'E F G "}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + out, err := XMLEscapeString(tt.input) + require.NoError(t, err) + require.Equal(t, tt.expected, out) + }) + } +} diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go index 24bf6479ba..247c653617 100644 --- a/server/mdm/apple/mobileconfig/profiles.go +++ b/server/mdm/apple/mobileconfig/profiles.go @@ -1,6 +1,12 @@ package mobileconfig -import "text/template" +import ( + "text/template" +) + +var funcMap = map[string]any{ + "xml": XMLEscapeString, +} // FleetdProfileOptions are the keys required to execute a // FleetdProfileTemplate. @@ -20,7 +26,7 @@ type FleetdProfileOptions struct { // // Internally, this is used by Fleet MDM to configure the installer delivered // to hosts during DEP enrollment. -var FleetdProfileTemplate = template.Must(template.New("").Option("missingkey=error").Parse(` +var FleetdProfileTemplate = template.Must(template.New("").Funcs(funcMap).Option("missingkey=error").Parse(` @@ -28,7 +34,7 @@ var FleetdProfileTemplate = template.Must(template.New("").Option("missingkey=er EnrollSecret - {{ .EnrollSecret }} + {{ .EnrollSecret | xml }} FleetURL {{ .ServerURL }} EnableScripts @@ -109,3 +115,34 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro `)) + +var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(funcMap).Option("missingkey=error").Parse(` + + + + PayloadContent + + URL + {{ .URL }} + DeviceAttributes + + UDID + VERSION + PRODUCT + SERIAL + + + PayloadOrganization + {{ xml .Organization }} + PayloadDisplayName + {{ xml .Organization }} enrollment + PayloadVersion + 1 + PayloadUUID + fdb376e5-b5bb-4d8c-829e-e90865f990c9 + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.ota + PayloadType + Profile Service + +`)) diff --git a/server/mdm/apple/util.go b/server/mdm/apple/util.go index 40b669da93..c98dcc48d8 100644 --- a/server/mdm/apple/util.go +++ b/server/mdm/apple/util.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" ) @@ -108,3 +109,37 @@ func EnrollURL(token string, appConfig *fleet.AppConfig) (string, error) { enrollURL.RawQuery = q.Encode() return enrollURL.String(), nil } + +// IsLessThanVersion returns true if the current version is less than the target version. +// If either version is invalid, an error is returned. +func IsLessThanVersion(current string, target string) (bool, error) { + cv, err := semver.NewVersion(current) + if err != nil { + return false, fmt.Errorf("invalid current version: %w", err) + } + tv, err := semver.NewVersion(target) + if err != nil { + return false, fmt.Errorf("invalid target version: %w", err) + } + + return cv.LessThan(tv), nil +} + +// CompareVersions returns an integer comparing two versions according to semantic version +// precedence. The result will be 0 if a == b, -1 if a < b, or +1 if a > b. +// An invalid semantic version string is considered less than a valid one. All invalid semantic +// version strings compare equal to each other. +func CompareVersions(a string, b string) int { + verA, errA := semver.NewVersion(a) + verB, errB := semver.NewVersion(b) + switch { + case errA != nil && errB != nil: + return 0 + case errA != nil: + return -1 + case errB != nil: + return 1 + default: + return verA.Compare(verB) + } +} diff --git a/server/mdm/assets/assets.go b/server/mdm/assets/assets.go index f1ca889ab8..af303b65ce 100644 --- a/server/mdm/assets/assets.go +++ b/server/mdm/assets/assets.go @@ -72,16 +72,20 @@ func APNSTopic(ctx context.Context, ds fleet.MDMAssetRetriever) (string, error) return mdmPushCertTopic, nil } -func ABMToken(ctx context.Context, ds fleet.MDMAssetRetriever) (*nanodep_client.OAuth1Tokens, error) { +func ABMToken(ctx context.Context, ds fleet.MDMAssetRetriever, abmOrgName string) (*nanodep_client.OAuth1Tokens, error) { assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetABMKey, fleet.MDMAssetABMCert, - fleet.MDMAssetABMToken, }) if err != nil { return nil, fmt.Errorf("loading ABM assets from the database: %w", err) } + abmTok, err := ds.GetABMTokenByOrgName(ctx, abmOrgName) + if err != nil { + return nil, fmt.Errorf("get ABM token by name: %w", err) + } + cert, err := tls.X509KeyPair(assets[fleet.MDMAssetABMCert].Value, assets[fleet.MDMAssetABMKey].Value) if err != nil { return nil, fmt.Errorf("parsing ABM keypair: %w", err) @@ -92,11 +96,16 @@ func ABMToken(ctx context.Context, ds fleet.MDMAssetRetriever) (*nanodep_client. return nil, fmt.Errorf("parsing ABM certificate: %w", err) } - return DecryptRawABMToken( - assets[fleet.MDMAssetABMToken].Value, + oAuthTok, err := DecryptRawABMToken( + abmTok.EncryptedToken, leaf, assets[fleet.MDMAssetABMKey].Value, ) + if err != nil { + return nil, fmt.Errorf("decrypting ABM token: %w", err) + } + + return oAuthTok, nil } func DecryptRawABMToken(tokenBytes []byte, cert *x509.Certificate, keyPEM []byte) (*nanodep_client.OAuth1Tokens, error) { diff --git a/server/mdm/assets/assets_test.go b/server/mdm/assets/assets_test.go index 42aaa9f006..b6484fd4b0 100644 --- a/server/mdm/assets/assets_test.go +++ b/server/mdm/assets/assets_test.go @@ -18,8 +18,8 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // generateTestCert generates a test certificate and key. @@ -185,22 +185,31 @@ func TestABMToken(t *testing.T) { "\r\n%s", base64.StdEncoding.EncodeToString(encryptedToken)) assets := map[fleet.MDMAssetName]fleet.MDMConfigAsset{ - fleet.MDMAssetABMCert: {Value: certPEM}, - fleet.MDMAssetABMKey: {Value: keyPEM}, - fleet.MDMAssetABMToken: {Value: []byte(tokenBytes)}, + fleet.MDMAssetABMCert: {Value: certPEM}, + fleet.MDMAssetABMKey: {Value: keyPEM}, } ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { require.ElementsMatch(t, []fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, }, assetNames) return assets, nil } + const testOrgName = "test-org" - tokens, err := ABMToken(ctx, ds) + ds.GetABMTokenByOrgNameFunc = func(ctx context.Context, orgName string) (*fleet.ABMToken, error) { + require.Equal(t, testOrgName, orgName) + return &fleet.ABMToken{ + ID: 1, + OrganizationName: testOrgName, + EncryptedToken: []byte(tokenBytes), + }, nil + } + + tokens, err := ABMToken(ctx, ds, testOrgName) require.NoError(t, err) require.NotNil(t, tokens) require.Equal(t, "test_access_secret", tokens.AccessSecret) require.True(t, ds.GetAllMDMConfigAssetsByNameFuncInvoked) + require.True(t, ds.GetABMTokenByOrgNameFuncInvoked) } diff --git a/server/mdm/crypto/scep.go b/server/mdm/crypto/scep.go index 1367030bf4..2ba39d37d6 100644 --- a/server/mdm/crypto/scep.go +++ b/server/mdm/crypto/scep.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/assets" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm" ) @@ -33,15 +34,21 @@ func (s *SCEPVerifier) Verify(cert *x509.Certificate) error { } // TODO(roberto): nano interfaces don't allow to pass a context to this function - assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ - fleet.MDMAssetCACert, - }) + rootCert, err := assets.X509Cert(context.Background(), s.ds, fleet.MDMAssetCACert) if err != nil { return fmt.Errorf("loading existing assets from the database: %w", err) } + opts.Roots.AddCert(rootCert) - if ok := opts.Roots.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value); !ok { - return errors.New("unable to append cerver SCEP cert to pool verifier") + // the default SCEP cert issued by fleet doesn't have any extra key + // usages, however, customers might configure the server with any + // certificate they want (generally for touchless MDM migrations) + // + // given that go verifies ext key usages on the whole chain, we relax + // the constraints when the provided certificate has any ext key usage + // that would cause a failure. + if hasOtherKeyUsages(rootCert, x509.ExtKeyUsageClientAuth) { + opts.KeyUsages = []x509.ExtKeyUsage{x509.ExtKeyUsageAny} } if _, err := cert.Verify(opts); err != nil { @@ -50,3 +57,12 @@ func (s *SCEPVerifier) Verify(cert *x509.Certificate) error { return nil } + +func hasOtherKeyUsages(cert *x509.Certificate, usage x509.ExtKeyUsage) bool { + for _, u := range cert.ExtKeyUsage { + if u != usage { + return true + } + } + return false +} diff --git a/server/mdm/crypto/scep_test.go b/server/mdm/crypto/scep_test.go index a8865b58f9..179864b9d3 100644 --- a/server/mdm/crypto/scep_test.go +++ b/server/mdm/crypto/scep_test.go @@ -1,8 +1,20 @@ package mdmcrypto import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" "github.com/stretchr/testify/require" ) @@ -11,3 +23,130 @@ func TestSCEPVerifierVerifyEmptyCerts(t *testing.T) { err := v.Verify(nil) require.ErrorContains(t, err, "no certificate provided") } + +func TestVerify(t *testing.T) { + ds := new(mock.Store) + verifier := NewSCEPVerifier(ds) + + // generate a valid root certificate with ExtKeyUsageClientAuth + validRootCertBytes, validRootCert, rootKey := generateRootCertificate(t, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}) + _, validClientCert := generateClientCertificate(t, validRootCert, rootKey) + + // generate a root certificate with an unrelated ExtKeyUsage + rootWithOtherUsagesBytes, rootWithOtherUsageCert, rootWithOtherUsageKey := generateRootCertificate(t, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) + _, validClientCertFromMultipleUsageRoot := generateClientCertificate(t, rootWithOtherUsageCert, rootWithOtherUsageKey) + + cases := []struct { + name string + rootCert []byte + certToVerify *x509.Certificate + wantErr string + }{ + { + name: "no certificate provided", + rootCert: nil, + certToVerify: nil, + wantErr: "no certificate provided", + }, + { + name: "error loading root cert from database", + rootCert: nil, + certToVerify: validClientCert, + wantErr: "loading existing assets from the database", + }, + { + name: "valid certificate verification succeeds", + rootCert: validRootCertBytes, + certToVerify: validClientCert, + wantErr: "", + }, + { + name: "valid certificate with unrelated key usage in root cert", + rootCert: rootWithOtherUsagesBytes, + certToVerify: validClientCertFromMultipleUsageRoot, + wantErr: "", + }, + { + name: "mismatched certificate presented", + rootCert: rootWithOtherUsagesBytes, + certToVerify: validClientCert, + wantErr: "certificate signed by unknown authority", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + if tt.rootCert == nil { + return nil, errors.New("test error") + } + + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetCACert: {Value: tt.rootCert}, + }, nil + } + + err := verifier.Verify(tt.certToVerify) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func generateRootCertificate(t *testing.T, extKeyUsages []x509.ExtKeyUsage) ([]byte, *x509.Certificate, *ecdsa.PrivateKey) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + rootCertTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test Root CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: extKeyUsages, + BasicConstraintsValid: true, + } + + rootCertDER, err := x509.CreateCertificate(rand.Reader, rootCertTemplate, rootCertTemplate, &priv.PublicKey, priv) + require.NoError(t, err) + + rootCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCertDER}) + + rootCert, err := x509.ParseCertificate(rootCertDER) + require.NoError(t, err) + + return rootCertPEM, rootCert, priv +} + +func generateClientCertificate(t *testing.T, rootCert *x509.Certificate, rootKey *ecdsa.PrivateKey) ([]byte, *x509.Certificate) { + clientPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + clientCertTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{ + Organization: []string{"Test Client"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(1 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + clientCertDER, err := x509.CreateCertificate(rand.Reader, clientCertTemplate, rootCert, &clientPriv.PublicKey, rootKey) + require.NoError(t, err) + + clientCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientCertDER}) + + clientCert, err := x509.ParseCertificate(clientCertDER) + require.NoError(t, err) + + return clientCertPEM, clientCert +} diff --git a/server/mdm/lifecycle/lifecycle.go b/server/mdm/lifecycle/lifecycle.go index 5587e71f4b..33658a2367 100644 --- a/server/mdm/lifecycle/lifecycle.go +++ b/server/mdm/lifecycle/lifecycle.go @@ -224,7 +224,7 @@ func (t *HostLifecycle) deleteDarwin(ctx context.Context, opts HostOptions) erro } if dep != nil && dep.DeletedAt == nil { - return t.restorePendingDEPHost(ctx, opts.Host, ac) + return t.restorePendingDEPHost(ctx, opts.Host, dep.ABMTokenID) } // no DEP assignment was found or the DEP assignment was deleted in ABM @@ -232,8 +232,12 @@ func (t *HostLifecycle) deleteDarwin(ctx context.Context, opts HostOptions) erro return nil } -func (t *HostLifecycle) restorePendingDEPHost(ctx context.Context, host *fleet.Host, appCfg *fleet.AppConfig) error { - tmID, err := t.getConfigAppleBMDefaultTeamID(ctx, appCfg) +func (t *HostLifecycle) restorePendingDEPHost(ctx context.Context, host *fleet.Host, abmTokenID *uint) error { + if abmTokenID == nil { + return ctxerr.New(ctx, "cannot restore pending dep host without valid ABM token id") + } + + tmID, err := t.getDefaultTeamForABMToken(ctx, host, *abmTokenID) if err != nil { return ctxerr.Wrap(ctx, err, "restore pending dep host") } @@ -251,25 +255,43 @@ func (t *HostLifecycle) restorePendingDEPHost(ctx context.Context, host *fleet.H return nil } -func (t *HostLifecycle) getConfigAppleBMDefaultTeamID(ctx context.Context, appCfg *fleet.AppConfig) (*uint, error) { - var tmID *uint - if name := appCfg.MDM.AppleBMDefaultTeam; name != "" { - team, err := t.ds.TeamByName(ctx, name) - switch { - case fleet.IsNotFound(err): - level.Debug(t.logger).Log( - "msg", - "unable to find default team assigned in config, mdm devices won't be assigned to a team", - "team_name", - name, - ) - return nil, nil - case err != nil: - return nil, ctxerr.Wrap(ctx, err, "get default team for mdm devices") - case team != nil: - tmID = &team.ID - } +func (t *HostLifecycle) getDefaultTeamForABMToken(ctx context.Context, host *fleet.Host, abmTokenID uint) (*uint, error) { + var abmDefaultTeamID *uint + tok, err := t.ds.GetABMTokenByID(ctx, abmTokenID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting ABM token by id") } - return tmID, nil + switch host.FleetPlatform() { + case "darwin": + abmDefaultTeamID = tok.MacOSDefaultTeamID + case "ios": + abmDefaultTeamID = tok.IOSDefaultTeamID + case "ipados": + abmDefaultTeamID = tok.IPadOSDefaultTeamID + default: + return nil, ctxerr.NewWithData(ctx, "attempting to get default ABM team for host with invalid platform", map[string]any{"host_platform": host.FleetPlatform(), "host_id": host.ID}) + } + + if abmDefaultTeamID == nil { + // The default team is "No team", so we can return nil + return nil, nil + } + + exists, err := t.ds.TeamExists(ctx, *abmDefaultTeamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get default team for mdm devices") + } + + if !exists { + level.Debug(t.logger).Log( + "msg", + "unable to find default team assigned to abm token, mdm devices won't be assigned to a team", + "team_id", + abmDefaultTeamID, + ) + return nil, nil + } + + return abmDefaultTeamID, nil } diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 93af98eb3d..5aaae483d8 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -6,7 +6,7 @@ import ( "crypto/x509" "encoding/base64" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // MaxProfileRetries is the maximum times an install profile command may be diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index a8a9254bd8..bc56572ee1 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -5,7 +5,7 @@ import ( "encoding/base64" "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) const ( diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 68346cde4f..beb0c01ac2 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -21,7 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/golang-jwt/jwt/v4" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // CertManager is an interface for certificate management tasks associated with Microsoft MDM (e.g., diff --git a/server/mdm/nanodep/godep/client.go b/server/mdm/nanodep/godep/client.go index 874db7546b..2d80a3dfd6 100644 --- a/server/mdm/nanodep/godep/client.go +++ b/server/mdm/nanodep/godep/client.go @@ -119,8 +119,14 @@ func NewClient(store ClientStorage, client *http.Client, opts ...ClientOption) * } func (c *Client) doWithAfterHook(ctx context.Context, name, method, path string, in interface{}, out interface{}) error { - err := c.do(ctx, name, method, path, in, out) + req, err := c.do(ctx, name, method, path, in, out) if c.afterHook != nil { + // ensure the afterHook is always called with the same context as the one + // used for the request (the DEP client will add the name argument to that + // context, which we care about in the after hook). + if req != nil { + ctx = req.Context() + } err = c.afterHook(ctx, err) } return err @@ -130,19 +136,19 @@ func (c *Client) doWithAfterHook(ctx context.Context, name, method, path string, // should be using the NanoDEP transport (which handles authentication). // This frees us to only be concerned about the actual DEP API request. // We encode in to JSON and decode any returned body as JSON to out. -func (c *Client) do(ctx context.Context, name, method, path string, in interface{}, out interface{}) error { +func (c *Client) do(ctx context.Context, name, method, path string, in interface{}, out interface{}) (*http.Request, error) { var body io.Reader if in != nil { bodyBytes, err := json.Marshal(in) if err != nil { - return err + return nil, err } body = bytes.NewBuffer(bodyBytes) } req, err := depclient.NewRequestWithContext(ctx, name, c.store, method, path, body) if err != nil { - return err + return nil, err } req.Header.Set("User-Agent", userAgent) if body != nil { @@ -154,22 +160,22 @@ func (c *Client) do(ctx context.Context, name, method, path string, in interface resp, err := c.client.Do(req) if err != nil { - return err + return req, err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("unhandled auth error: %w", depclient.NewAuthError(resp)) + return req, fmt.Errorf("unhandled auth error: %w", depclient.NewAuthError(resp)) } else if resp.StatusCode != http.StatusOK { - return NewHTTPError(resp) + return req, NewHTTPError(resp) } if out != nil { err := json.NewDecoder(resp.Body).Decode(out) if err != nil { - return err + return req, err } } - return nil + return req, nil } diff --git a/server/mdm/nanodep/tokenpki/parse.go b/server/mdm/nanodep/tokenpki/parse.go index 6d254403f1..296dc43edf 100644 --- a/server/mdm/nanodep/tokenpki/parse.go +++ b/server/mdm/nanodep/tokenpki/parse.go @@ -11,7 +11,7 @@ import ( "io" "net/textproto" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // UnwrapSMIME removes the S/MIME-like header wrapper around the raw encrypted diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil.go b/server/mdm/nanomdm/cryptoutil/cryptoutil.go index d7e41fcf10..ad62b4587a 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil.go @@ -10,7 +10,7 @@ import ( "fmt" "strings" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // OID for UID (User ID) attribute diff --git a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go index 0c3eda3296..5b5291709c 100644 --- a/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go +++ b/server/mdm/nanomdm/cryptoutil/cryptoutil_test.go @@ -4,7 +4,7 @@ import ( "encoding/base64" "testing" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) func TestPKCS7ParseTagLengthError(t *testing.T) { diff --git a/server/mdm/scep/scep/scep.go b/server/mdm/scep/scep/scep.go index 25fa1de349..dc2bb1fcc8 100644 --- a/server/mdm/scep/scep/scep.go +++ b/server/mdm/scep/scep/scep.go @@ -19,7 +19,7 @@ import ( "github.com/go-kit/log" "github.com/go-kit/log/level" - "go.mozilla.org/pkcs7" + "github.com/smallstep/pkcs7" ) // errors diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e062476d60..a592559bdf 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -250,7 +250,15 @@ type GetHostMDMFunc func(ctx context.Context, hostID uint) (*fleet.HostMDM, erro type GetHostMDMCheckinInfoFunc func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) -type ListIOSAndIPadOSToRefetchFunc func(ctx context.Context, refetchInterval time.Duration) (uuids []string, err error) +type ListIOSAndIPadOSToRefetchFunc func(ctx context.Context, refetchInterval time.Duration) (devices []fleet.AppleDevicesToRefetch, err error) + +type AddHostMDMCommandsFunc func(ctx context.Context, commands []fleet.HostMDMCommand) error + +type GetHostMDMCommandsFunc func(ctx context.Context, hostID uint) (commands []fleet.HostMDMCommand, err error) + +type RemoveHostMDMCommandFunc func(ctx context.Context, command fleet.HostMDMCommand) error + +type CleanupHostMDMCommandsFunc func(ctx context.Context) error type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) (bool, error) @@ -388,7 +396,11 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) -type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) +type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) + +type InsertSoftwareUninstallRequestFunc func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error + +type GetSoftwareTitleNameFromExecutionIDFunc func(ctx context.Context, executionID string) (string, error) type ListSoftwareForVulnDetectionFunc func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) @@ -486,6 +498,8 @@ type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[s type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) +type GetPoliciesWithAssociatedInstallerFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) + type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error @@ -604,6 +618,8 @@ type SetOrUpdateMunkiInfoFunc func(ctx context.Context, hostID uint, version str type SetOrUpdateMDMDataFunc func(ctx context.Context, hostID uint, isServer bool, enrolled bool, serverURL string, installedFromDep bool, name string, fleetEnrollRef string) error +type UpdateMDMDataFunc func(ctx context.Context, hostID uint, enrolled bool) error + type SetOrUpdateHostEmailsFromMdmIdpAccountsFunc func(ctx context.Context, hostID uint, fleetEnrollmentRef string) error type SetOrUpdateHostDisksSpaceFunc func(ctx context.Context, hostID uint, gigsAvailable float64, percentAvailable float64, gigsTotal float64) error @@ -684,6 +700,8 @@ type CountVulnerabilitiesFunc func(ctx context.Context, opt fleet.VulnListOption type UpdateVulnerabilityHostCountsFunc func(ctx context.Context) error +type IsCVEKnownToFleetFunc func(ctx context.Context, cve string) (bool, error) + type NewMDMAppleConfigProfileFunc func(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) type BulkUpsertMDMAppleConfigProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleConfigProfile) error @@ -738,9 +756,11 @@ type BatchSetMDMAppleProfilesFunc func(ctx context.Context, tmID *uint, profiles type MDMAppleListDevicesFunc func(ctx context.Context) ([]fleet.MDMAppleDevice, error) -type UpsertMDMAppleHostDEPAssignmentsFunc func(ctx context.Context, hosts []fleet.Host) error +type UpsertMDMAppleHostDEPAssignmentsFunc func(ctx context.Context, hosts []fleet.Host, abmTokenID uint) error -type IngestMDMAppleDevicesFromDEPSyncFunc func(ctx context.Context, devices []godep.Device) (int64, *uint, error) +type IngestMDMAppleDevicesFromDEPSyncFunc func(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam *fleet.Team, iosTeam *fleet.Team, ipadTeam *fleet.Team) (int64, error) + +type IngestMDMAppleDeviceFromOTAEnrollmentFunc func(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error type MDMAppleUpsertHostFunc func(ctx context.Context, mdmHost *fleet.Host) error @@ -766,7 +786,7 @@ type ListMDMAppleProfilesToRemoveFunc func(ctx context.Context) ([]*fleet.MDMApp type BulkUpsertMDMAppleHostProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error -type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error +type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) type GetMDMAppleProfilesContentsFunc func(ctx context.Context, profileUUIDs []string) (map[string]mobileconfig.Mobileconfig, error) @@ -784,7 +804,7 @@ type GetMDMIdPAccountByEmailFunc func(ctx context.Context, email string) (*fleet type GetMDMAppleFileVaultSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) -type InsertMDMAppleBootstrapPackageFunc func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error +type InsertMDMAppleBootstrapPackageFunc func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error type CopyDefaultMDMAppleBootstrapPackageFunc func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error @@ -792,12 +812,14 @@ type DeleteMDMAppleBootstrapPackageFunc func(ctx context.Context, teamID uint) e type GetMDMAppleBootstrapPackageMetaFunc func(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackage, error) -type GetMDMAppleBootstrapPackageBytesFunc func(ctx context.Context, token string) (*fleet.MDMAppleBootstrapPackage, error) +type GetMDMAppleBootstrapPackageBytesFunc func(ctx context.Context, token string, pkgStore fleet.MDMBootstrapPackageStore) (*fleet.MDMAppleBootstrapPackage, error) type GetMDMAppleBootstrapPackageSummaryFunc func(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackageSummary, error) type RecordHostBootstrapPackageFunc func(ctx context.Context, commandUUID string, hostUUID string) error +type CleanupUnusedBootstrapPackagesFunc func(ctx context.Context, pkgStore fleet.MDMBootstrapPackageStore, removeCreatedBefore time.Time) error + type GetHostMDMMacOSSetupFunc func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) type MDMGetEULAMetadataFunc func(ctx context.Context) (*fleet.MDMEULA, error) @@ -812,13 +834,15 @@ type SetOrUpdateMDMAppleSetupAssistantFunc func(ctx context.Context, asst *fleet type GetMDMAppleSetupAssistantFunc func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) +type GetMDMAppleSetupAssistantProfileForABMTokenFunc func(ctx context.Context, teamID *uint, abmTokenOrgName string) (string, time.Time, error) + type DeleteMDMAppleSetupAssistantFunc func(ctx context.Context, teamID *uint) error -type SetMDMAppleSetupAssistantProfileUUIDFunc func(ctx context.Context, teamID *uint, profileUUID string) error +type SetMDMAppleSetupAssistantProfileUUIDFunc func(ctx context.Context, teamID *uint, profileUUID string, abmTokenOrgName string) error -type SetMDMAppleDefaultSetupAssistantProfileUUIDFunc func(ctx context.Context, teamID *uint, profileUUID string) error +type SetMDMAppleDefaultSetupAssistantProfileUUIDFunc func(ctx context.Context, teamID *uint, profileUUID string, abmTokenOrgName string) error -type GetMDMAppleDefaultSetupAssistantFunc func(ctx context.Context, teamID *uint) (profileUUID string, updatedAt time.Time, err error) +type GetMDMAppleDefaultSetupAssistantFunc func(ctx context.Context, teamID *uint, abmTokenOrgName string) (profileUUID string, updatedAt time.Time, err error) type GetMatchingHostSerialsFunc func(ctx context.Context, serials []string) (map[string]*fleet.Host, error) @@ -826,7 +850,7 @@ type DeleteHostDEPAssignmentsFunc func(ctx context.Context, serials []string) er 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 ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) type GetDEPAssignProfileExpiredCooldownsFunc func(ctx context.Context) (map[uint][]string, error) @@ -846,6 +870,8 @@ type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, type MDMAppleSetPendingDeclarationsAsFunc func(ctx context.Context, hostUUID string, status *fleet.MDMDeliveryStatus, detail string) error +type GetMDMAppleOSUpdatesSettingsByHostSerialFunc func(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) + type InsertMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) @@ -856,6 +882,40 @@ type DeleteMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []flee type ReplaceMDMConfigAssetsFunc func(ctx context.Context, assets []fleet.MDMConfigAsset) error +type GetABMTokenByOrgNameFunc func(ctx context.Context, orgName string) (*fleet.ABMToken, error) + +type SaveABMTokenFunc func(ctx context.Context, tok *fleet.ABMToken) error + +type InsertVPPTokenFunc func(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) + +type ListVPPTokensFunc func(ctx context.Context) ([]*fleet.VPPTokenDB, error) + +type GetVPPTokenFunc func(ctx context.Context, tokenID uint) (*fleet.VPPTokenDB, error) + +type GetVPPTokenByTeamIDFunc func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) + +type UpdateVPPTokenTeamsFunc func(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) + +type UpdateVPPTokenFunc func(ctx context.Context, id uint, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) + +type DeleteVPPTokenFunc func(ctx context.Context, tokenID uint) error + +type SetABMTokenTermsExpiredForOrgNameFunc func(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) + +type CountABMTokensWithTermsExpiredFunc func(ctx context.Context) (int, error) + +type InsertABMTokenFunc func(ctx context.Context, tok *fleet.ABMToken) (*fleet.ABMToken, error) + +type ListABMTokensFunc func(ctx context.Context) ([]*fleet.ABMToken, error) + +type DeleteABMTokenFunc func(ctx context.Context, tokenID uint) error + +type GetABMTokenByIDFunc func(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) + +type GetABMTokenCountFunc func(ctx context.Context) (int, error) + +type GetABMTokenOrgNamesAssociatedWithTeamFunc func(ctx context.Context, teamID *uint) ([]string, error) + type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error) @@ -918,7 +978,7 @@ type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindow type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error -type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error +type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) @@ -926,7 +986,7 @@ type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *f type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) -type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) +type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (hsr *fleet.HostScriptResult, action string, err error) type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) @@ -938,6 +998,8 @@ type ScriptFunc func(ctx context.Context, id uint) (*fleet.Script, error) type GetScriptContentsFunc func(ctx context.Context, id uint) ([]byte, error) +type GetAnyScriptContentsFunc func(ctx context.Context, id uint) ([]byte, error) + type DeleteScriptFunc func(ctx context.Context, id uint) error type ListScriptsFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) @@ -972,13 +1034,27 @@ type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) +type GetHostLastInstallDataFunc func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) + type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) +type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID uint, installerID uint) (bool, error) + type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) -type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) +type GetSoftwareInstallersWithoutPackageIDsFunc func(ctx context.Context) (map[uint]string, error) + +type UpdateSoftwareInstallerWithoutPackageIDsFunc func(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error + +type ProcessInstallerUpdateSideEffectsFunc func(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error + +type SaveInstallerUpdatesFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error + +type UpdateInstallerSelfServiceFlagFunc func(ctx context.Context, selfService bool, id uint) error + +type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) type GetVPPAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) @@ -992,24 +1068,28 @@ type GetSummaryHostVPPAppInstallsFunc func(ctx context.Context, teamID *uint, ap type GetSoftwareInstallResultsFunc func(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) -type CleanupUnusedSoftwareInstallersFunc func(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore) error +type CleanupUnusedSoftwareInstallersFunc func(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error +type GetSoftwareInstallersFunc func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) + type HasSelfServiceSoftwareInstallersFunc func(ctx context.Context, platform string, teamID *uint) (bool, error) type BatchInsertVPPAppsFunc func(ctx context.Context, apps []*fleet.VPPApp) error -type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]struct{}, error) +type GetAssignedVPPAppsFunc func(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) -type SetTeamVPPAppsFunc func(ctx context.Context, teamID *uint, appIDs []fleet.VPPAppID) error +type SetTeamVPPAppsFunc func(ctx context.Context, teamID *uint, appIDs []fleet.VPPAppTeam) error type InsertVPPAppWithTeamFunc func(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) -type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, userID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string) error +type InsertHostVPPSoftwareInstallFunc func(ctx context.Context, hostID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string, selfService bool) error type GetPastActivityDataForVPPAppInstallFunc func(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) +type GetVPPTokenByLocationFunc func(ctx context.Context, loc string) (*fleet.VPPTokenDB, error) + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -1359,6 +1439,18 @@ type DataStore struct { ListIOSAndIPadOSToRefetchFunc ListIOSAndIPadOSToRefetchFunc ListIOSAndIPadOSToRefetchFuncInvoked bool + AddHostMDMCommandsFunc AddHostMDMCommandsFunc + AddHostMDMCommandsFuncInvoked bool + + GetHostMDMCommandsFunc GetHostMDMCommandsFunc + GetHostMDMCommandsFuncInvoked bool + + RemoveHostMDMCommandFunc RemoveHostMDMCommandFunc + RemoveHostMDMCommandFuncInvoked bool + + CleanupHostMDMCommandsFunc CleanupHostMDMCommandsFunc + CleanupHostMDMCommandsFuncInvoked bool + IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFuncInvoked bool @@ -1566,6 +1658,12 @@ type DataStore struct { InsertSoftwareInstallRequestFunc InsertSoftwareInstallRequestFunc InsertSoftwareInstallRequestFuncInvoked bool + InsertSoftwareUninstallRequestFunc InsertSoftwareUninstallRequestFunc + InsertSoftwareUninstallRequestFuncInvoked bool + + GetSoftwareTitleNameFromExecutionIDFunc GetSoftwareTitleNameFromExecutionIDFunc + GetSoftwareTitleNameFromExecutionIDFuncInvoked bool + ListSoftwareForVulnDetectionFunc ListSoftwareForVulnDetectionFunc ListSoftwareForVulnDetectionFuncInvoked bool @@ -1710,6 +1808,9 @@ type DataStore struct { GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFuncInvoked bool + GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFunc + GetPoliciesWithAssociatedInstallerFuncInvoked bool + GetCalendarPoliciesFunc GetCalendarPoliciesFunc GetCalendarPoliciesFuncInvoked bool @@ -1887,6 +1988,9 @@ type DataStore struct { SetOrUpdateMDMDataFunc SetOrUpdateMDMDataFunc SetOrUpdateMDMDataFuncInvoked bool + UpdateMDMDataFunc UpdateMDMDataFunc + UpdateMDMDataFuncInvoked bool + SetOrUpdateHostEmailsFromMdmIdpAccountsFunc SetOrUpdateHostEmailsFromMdmIdpAccountsFunc SetOrUpdateHostEmailsFromMdmIdpAccountsFuncInvoked bool @@ -2007,6 +2111,9 @@ type DataStore struct { UpdateVulnerabilityHostCountsFunc UpdateVulnerabilityHostCountsFunc UpdateVulnerabilityHostCountsFuncInvoked bool + IsCVEKnownToFleetFunc IsCVEKnownToFleetFunc + IsCVEKnownToFleetFuncInvoked bool + NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFunc NewMDMAppleConfigProfileFuncInvoked bool @@ -2094,6 +2201,9 @@ type DataStore struct { IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFuncInvoked bool + IngestMDMAppleDeviceFromOTAEnrollmentFunc IngestMDMAppleDeviceFromOTAEnrollmentFunc + IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked bool + MDMAppleUpsertHostFunc MDMAppleUpsertHostFunc MDMAppleUpsertHostFuncInvoked bool @@ -2178,6 +2288,9 @@ type DataStore struct { RecordHostBootstrapPackageFunc RecordHostBootstrapPackageFunc RecordHostBootstrapPackageFuncInvoked bool + CleanupUnusedBootstrapPackagesFunc CleanupUnusedBootstrapPackagesFunc + CleanupUnusedBootstrapPackagesFuncInvoked bool + GetHostMDMMacOSSetupFunc GetHostMDMMacOSSetupFunc GetHostMDMMacOSSetupFuncInvoked bool @@ -2199,6 +2312,9 @@ type DataStore struct { GetMDMAppleSetupAssistantFunc GetMDMAppleSetupAssistantFunc GetMDMAppleSetupAssistantFuncInvoked bool + GetMDMAppleSetupAssistantProfileForABMTokenFunc GetMDMAppleSetupAssistantProfileForABMTokenFunc + GetMDMAppleSetupAssistantProfileForABMTokenFuncInvoked bool + DeleteMDMAppleSetupAssistantFunc DeleteMDMAppleSetupAssistantFunc DeleteMDMAppleSetupAssistantFuncInvoked bool @@ -2250,6 +2366,9 @@ type DataStore struct { MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFunc MDMAppleSetPendingDeclarationsAsFuncInvoked bool + GetMDMAppleOSUpdatesSettingsByHostSerialFunc GetMDMAppleOSUpdatesSettingsByHostSerialFunc + GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked bool + InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFunc InsertMDMConfigAssetsFuncInvoked bool @@ -2265,6 +2384,57 @@ type DataStore struct { ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFunc ReplaceMDMConfigAssetsFuncInvoked bool + GetABMTokenByOrgNameFunc GetABMTokenByOrgNameFunc + GetABMTokenByOrgNameFuncInvoked bool + + SaveABMTokenFunc SaveABMTokenFunc + SaveABMTokenFuncInvoked bool + + InsertVPPTokenFunc InsertVPPTokenFunc + InsertVPPTokenFuncInvoked bool + + ListVPPTokensFunc ListVPPTokensFunc + ListVPPTokensFuncInvoked bool + + GetVPPTokenFunc GetVPPTokenFunc + GetVPPTokenFuncInvoked bool + + GetVPPTokenByTeamIDFunc GetVPPTokenByTeamIDFunc + GetVPPTokenByTeamIDFuncInvoked bool + + UpdateVPPTokenTeamsFunc UpdateVPPTokenTeamsFunc + UpdateVPPTokenTeamsFuncInvoked bool + + UpdateVPPTokenFunc UpdateVPPTokenFunc + UpdateVPPTokenFuncInvoked bool + + DeleteVPPTokenFunc DeleteVPPTokenFunc + DeleteVPPTokenFuncInvoked bool + + SetABMTokenTermsExpiredForOrgNameFunc SetABMTokenTermsExpiredForOrgNameFunc + SetABMTokenTermsExpiredForOrgNameFuncInvoked bool + + CountABMTokensWithTermsExpiredFunc CountABMTokensWithTermsExpiredFunc + CountABMTokensWithTermsExpiredFuncInvoked bool + + InsertABMTokenFunc InsertABMTokenFunc + InsertABMTokenFuncInvoked bool + + ListABMTokensFunc ListABMTokensFunc + ListABMTokensFuncInvoked bool + + DeleteABMTokenFunc DeleteABMTokenFunc + DeleteABMTokenFuncInvoked bool + + GetABMTokenByIDFunc GetABMTokenByIDFunc + GetABMTokenByIDFuncInvoked bool + + GetABMTokenCountFunc GetABMTokenCountFunc + GetABMTokenCountFuncInvoked bool + + GetABMTokenOrgNamesAssociatedWithTeamFunc GetABMTokenOrgNamesAssociatedWithTeamFunc + GetABMTokenOrgNamesAssociatedWithTeamFuncInvoked bool + WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc WSTEPStoreCertificateFuncInvoked bool @@ -2388,6 +2558,9 @@ type DataStore struct { GetScriptContentsFunc GetScriptContentsFunc GetScriptContentsFuncInvoked bool + GetAnyScriptContentsFunc GetAnyScriptContentsFunc + GetAnyScriptContentsFuncInvoked bool + DeleteScriptFunc DeleteScriptFunc DeleteScriptFuncInvoked bool @@ -2439,15 +2612,36 @@ type DataStore struct { ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFuncInvoked bool + GetHostLastInstallDataFunc GetHostLastInstallDataFunc + GetHostLastInstallDataFuncInvoked bool + MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFuncInvoked bool GetSoftwareInstallerMetadataByIDFunc GetSoftwareInstallerMetadataByIDFunc GetSoftwareInstallerMetadataByIDFuncInvoked bool + ValidateOrbitSoftwareInstallerAccessFunc ValidateOrbitSoftwareInstallerAccessFunc + ValidateOrbitSoftwareInstallerAccessFuncInvoked bool + GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallersWithoutPackageIDsFunc GetSoftwareInstallersWithoutPackageIDsFunc + GetSoftwareInstallersWithoutPackageIDsFuncInvoked bool + + UpdateSoftwareInstallerWithoutPackageIDsFunc UpdateSoftwareInstallerWithoutPackageIDsFunc + UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked bool + + ProcessInstallerUpdateSideEffectsFunc ProcessInstallerUpdateSideEffectsFunc + ProcessInstallerUpdateSideEffectsFuncInvoked bool + + SaveInstallerUpdatesFunc SaveInstallerUpdatesFunc + SaveInstallerUpdatesFuncInvoked bool + + UpdateInstallerSelfServiceFlagFunc UpdateInstallerSelfServiceFlagFunc + UpdateInstallerSelfServiceFlagFuncInvoked bool + GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFuncInvoked bool @@ -2475,6 +2669,9 @@ type DataStore struct { BatchSetSoftwareInstallersFunc BatchSetSoftwareInstallersFunc BatchSetSoftwareInstallersFuncInvoked bool + GetSoftwareInstallersFunc GetSoftwareInstallersFunc + GetSoftwareInstallersFuncInvoked bool + HasSelfServiceSoftwareInstallersFunc HasSelfServiceSoftwareInstallersFunc HasSelfServiceSoftwareInstallersFuncInvoked bool @@ -2496,6 +2693,9 @@ type DataStore struct { GetPastActivityDataForVPPAppInstallFunc GetPastActivityDataForVPPAppInstallFunc GetPastActivityDataForVPPAppInstallFuncInvoked bool + GetVPPTokenByLocationFunc GetVPPTokenByLocationFunc + GetVPPTokenByLocationFuncInvoked bool + mu sync.Mutex } @@ -3304,13 +3504,41 @@ func (s *DataStore) GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) return s.GetHostMDMCheckinInfoFunc(ctx, hostUUID) } -func (s *DataStore) ListIOSAndIPadOSToRefetch(ctx context.Context, refetchInterval time.Duration) (uuids []string, err error) { +func (s *DataStore) ListIOSAndIPadOSToRefetch(ctx context.Context, refetchInterval time.Duration) (devices []fleet.AppleDevicesToRefetch, err error) { s.mu.Lock() s.ListIOSAndIPadOSToRefetchFuncInvoked = true s.mu.Unlock() return s.ListIOSAndIPadOSToRefetchFunc(ctx, refetchInterval) } +func (s *DataStore) AddHostMDMCommands(ctx context.Context, commands []fleet.HostMDMCommand) error { + s.mu.Lock() + s.AddHostMDMCommandsFuncInvoked = true + s.mu.Unlock() + return s.AddHostMDMCommandsFunc(ctx, commands) +} + +func (s *DataStore) GetHostMDMCommands(ctx context.Context, hostID uint) (commands []fleet.HostMDMCommand, err error) { + s.mu.Lock() + s.GetHostMDMCommandsFuncInvoked = true + s.mu.Unlock() + return s.GetHostMDMCommandsFunc(ctx, hostID) +} + +func (s *DataStore) RemoveHostMDMCommand(ctx context.Context, command fleet.HostMDMCommand) error { + s.mu.Lock() + s.RemoveHostMDMCommandFuncInvoked = true + s.mu.Unlock() + return s.RemoveHostMDMCommandFunc(ctx, command) +} + +func (s *DataStore) CleanupHostMDMCommands(ctx context.Context) error { + s.mu.Lock() + s.CleanupHostMDMCommandsFuncInvoked = true + s.mu.Unlock() + return s.CleanupHostMDMCommandsFunc(ctx) +} + func (s *DataStore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.Host) (bool, error) { s.mu.Lock() s.IsHostConnectedToFleetMDMFuncInvoked = true @@ -3787,11 +4015,25 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter) } -func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) { +func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) { s.mu.Lock() s.InsertSoftwareInstallRequestFuncInvoked = true s.mu.Unlock() - return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService) + return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareInstallerID, selfService) +} + +func (s *DataStore) InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error { + s.mu.Lock() + s.InsertSoftwareUninstallRequestFuncInvoked = true + s.mu.Unlock() + return s.InsertSoftwareUninstallRequestFunc(ctx, executionID, hostID, softwareInstallerID) +} + +func (s *DataStore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error) { + s.mu.Lock() + s.GetSoftwareTitleNameFromExecutionIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareTitleNameFromExecutionIDFunc(ctx, executionID) } func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) { @@ -4130,6 +4372,13 @@ func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain st return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID) } +func (s *DataStore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + s.mu.Lock() + s.GetPoliciesWithAssociatedInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetPoliciesWithAssociatedInstallerFunc(ctx, teamID, policyIDs) +} + func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { s.mu.Lock() s.GetCalendarPoliciesFuncInvoked = true @@ -4543,6 +4792,13 @@ func (s *DataStore) SetOrUpdateMDMData(ctx context.Context, hostID uint, isServe return s.SetOrUpdateMDMDataFunc(ctx, hostID, isServer, enrolled, serverURL, installedFromDep, name, fleetEnrollRef) } +func (s *DataStore) UpdateMDMData(ctx context.Context, hostID uint, enrolled bool) error { + s.mu.Lock() + s.UpdateMDMDataFuncInvoked = true + s.mu.Unlock() + return s.UpdateMDMDataFunc(ctx, hostID, enrolled) +} + func (s *DataStore) SetOrUpdateHostEmailsFromMdmIdpAccounts(ctx context.Context, hostID uint, fleetEnrollmentRef string) error { s.mu.Lock() s.SetOrUpdateHostEmailsFromMdmIdpAccountsFuncInvoked = true @@ -4823,6 +5079,13 @@ func (s *DataStore) UpdateVulnerabilityHostCounts(ctx context.Context) error { return s.UpdateVulnerabilityHostCountsFunc(ctx) } +func (s *DataStore) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + s.mu.Lock() + s.IsCVEKnownToFleetFuncInvoked = true + s.mu.Unlock() + return s.IsCVEKnownToFleetFunc(ctx, cve) +} + func (s *DataStore) NewMDMAppleConfigProfile(ctx context.Context, p fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { s.mu.Lock() s.NewMDMAppleConfigProfileFuncInvoked = true @@ -5012,18 +5275,25 @@ func (s *DataStore) MDMAppleListDevices(ctx context.Context) ([]fleet.MDMAppleDe return s.MDMAppleListDevicesFunc(ctx) } -func (s *DataStore) UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []fleet.Host) error { +func (s *DataStore) UpsertMDMAppleHostDEPAssignments(ctx context.Context, hosts []fleet.Host, abmTokenID uint) error { s.mu.Lock() s.UpsertMDMAppleHostDEPAssignmentsFuncInvoked = true s.mu.Unlock() - return s.UpsertMDMAppleHostDEPAssignmentsFunc(ctx, hosts) + return s.UpsertMDMAppleHostDEPAssignmentsFunc(ctx, hosts, abmTokenID) } -func (s *DataStore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device) (int64, *uint, error) { +func (s *DataStore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device, abmTokenID uint, macOSTeam *fleet.Team, iosTeam *fleet.Team, ipadTeam *fleet.Team) (int64, error) { s.mu.Lock() s.IngestMDMAppleDevicesFromDEPSyncFuncInvoked = true s.mu.Unlock() - return s.IngestMDMAppleDevicesFromDEPSyncFunc(ctx, devices) + return s.IngestMDMAppleDevicesFromDEPSyncFunc(ctx, devices, abmTokenID, macOSTeam, iosTeam, ipadTeam) +} + +func (s *DataStore) IngestMDMAppleDeviceFromOTAEnrollment(ctx context.Context, teamID *uint, deviceInfo fleet.MDMAppleMachineInfo) error { + s.mu.Lock() + s.IngestMDMAppleDeviceFromOTAEnrollmentFuncInvoked = true + s.mu.Unlock() + return s.IngestMDMAppleDeviceFromOTAEnrollmentFunc(ctx, teamID, deviceInfo) } func (s *DataStore) MDMAppleUpsertHost(ctx context.Context, mdmHost *fleet.Host) error { @@ -5110,7 +5380,7 @@ func (s *DataStore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload return s.BulkUpsertMDMAppleHostProfilesFunc(ctx, payload) } -func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { +func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BulkSetPendingMDMHostProfilesFuncInvoked = true s.mu.Unlock() @@ -5173,11 +5443,11 @@ func (s *DataStore) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uin return s.GetMDMAppleFileVaultSummaryFunc(ctx, teamID) } -func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { +func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error { s.mu.Lock() s.InsertMDMAppleBootstrapPackageFuncInvoked = true s.mu.Unlock() - return s.InsertMDMAppleBootstrapPackageFunc(ctx, bp) + return s.InsertMDMAppleBootstrapPackageFunc(ctx, bp, pkgStore) } func (s *DataStore) CopyDefaultMDMAppleBootstrapPackage(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { @@ -5201,11 +5471,11 @@ func (s *DataStore) GetMDMAppleBootstrapPackageMeta(ctx context.Context, teamID return s.GetMDMAppleBootstrapPackageMetaFunc(ctx, teamID) } -func (s *DataStore) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*fleet.MDMAppleBootstrapPackage, error) { +func (s *DataStore) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string, pkgStore fleet.MDMBootstrapPackageStore) (*fleet.MDMAppleBootstrapPackage, error) { s.mu.Lock() s.GetMDMAppleBootstrapPackageBytesFuncInvoked = true s.mu.Unlock() - return s.GetMDMAppleBootstrapPackageBytesFunc(ctx, token) + return s.GetMDMAppleBootstrapPackageBytesFunc(ctx, token, pkgStore) } func (s *DataStore) GetMDMAppleBootstrapPackageSummary(ctx context.Context, teamID uint) (*fleet.MDMAppleBootstrapPackageSummary, error) { @@ -5222,6 +5492,13 @@ func (s *DataStore) RecordHostBootstrapPackage(ctx context.Context, commandUUID return s.RecordHostBootstrapPackageFunc(ctx, commandUUID, hostUUID) } +func (s *DataStore) CleanupUnusedBootstrapPackages(ctx context.Context, pkgStore fleet.MDMBootstrapPackageStore, removeCreatedBefore time.Time) error { + s.mu.Lock() + s.CleanupUnusedBootstrapPackagesFuncInvoked = true + s.mu.Unlock() + return s.CleanupUnusedBootstrapPackagesFunc(ctx, pkgStore, removeCreatedBefore) +} + func (s *DataStore) GetHostMDMMacOSSetup(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { s.mu.Lock() s.GetHostMDMMacOSSetupFuncInvoked = true @@ -5271,6 +5548,13 @@ func (s *DataStore) GetMDMAppleSetupAssistant(ctx context.Context, teamID *uint) return s.GetMDMAppleSetupAssistantFunc(ctx, teamID) } +func (s *DataStore) GetMDMAppleSetupAssistantProfileForABMToken(ctx context.Context, teamID *uint, abmTokenOrgName string) (string, time.Time, error) { + s.mu.Lock() + s.GetMDMAppleSetupAssistantProfileForABMTokenFuncInvoked = true + s.mu.Unlock() + return s.GetMDMAppleSetupAssistantProfileForABMTokenFunc(ctx, teamID, abmTokenOrgName) +} + func (s *DataStore) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *uint) error { s.mu.Lock() s.DeleteMDMAppleSetupAssistantFuncInvoked = true @@ -5278,25 +5562,25 @@ func (s *DataStore) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui return s.DeleteMDMAppleSetupAssistantFunc(ctx, teamID) } -func (s *DataStore) SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error { +func (s *DataStore) SetMDMAppleSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string, abmTokenOrgName string) error { s.mu.Lock() s.SetMDMAppleSetupAssistantProfileUUIDFuncInvoked = true s.mu.Unlock() - return s.SetMDMAppleSetupAssistantProfileUUIDFunc(ctx, teamID, profileUUID) + return s.SetMDMAppleSetupAssistantProfileUUIDFunc(ctx, teamID, profileUUID, abmTokenOrgName) } -func (s *DataStore) SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string) error { +func (s *DataStore) SetMDMAppleDefaultSetupAssistantProfileUUID(ctx context.Context, teamID *uint, profileUUID string, abmTokenOrgName string) error { s.mu.Lock() s.SetMDMAppleDefaultSetupAssistantProfileUUIDFuncInvoked = true s.mu.Unlock() - return s.SetMDMAppleDefaultSetupAssistantProfileUUIDFunc(ctx, teamID, profileUUID) + return s.SetMDMAppleDefaultSetupAssistantProfileUUIDFunc(ctx, teamID, profileUUID, abmTokenOrgName) } -func (s *DataStore) GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint) (profileUUID string, updatedAt time.Time, err error) { +func (s *DataStore) GetMDMAppleDefaultSetupAssistant(ctx context.Context, teamID *uint, abmTokenOrgName string) (profileUUID string, updatedAt time.Time, err error) { s.mu.Lock() s.GetMDMAppleDefaultSetupAssistantFuncInvoked = true s.mu.Unlock() - return s.GetMDMAppleDefaultSetupAssistantFunc(ctx, teamID) + return s.GetMDMAppleDefaultSetupAssistantFunc(ctx, teamID, abmTokenOrgName) } func (s *DataStore) GetMatchingHostSerials(ctx context.Context, serials []string) (map[string]*fleet.Host, error) { @@ -5320,7 +5604,7 @@ func (s *DataStore) UpdateHostDEPAssignProfileResponses(ctx context.Context, res return s.UpdateHostDEPAssignProfileResponsesFunc(ctx, resp) } -func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, assignSerials []string, err error) { +func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) { s.mu.Lock() s.ScreenDEPAssignProfileSerialsForCooldownFuncInvoked = true s.mu.Unlock() @@ -5390,6 +5674,13 @@ func (s *DataStore) MDMAppleSetPendingDeclarationsAs(ctx context.Context, hostUU return s.MDMAppleSetPendingDeclarationsAsFunc(ctx, hostUUID, status, detail) } +func (s *DataStore) GetMDMAppleOSUpdatesSettingsByHostSerial(ctx context.Context, hostSerial string) (*fleet.AppleOSUpdateSettings, error) { + s.mu.Lock() + s.GetMDMAppleOSUpdatesSettingsByHostSerialFuncInvoked = true + s.mu.Unlock() + return s.GetMDMAppleOSUpdatesSettingsByHostSerialFunc(ctx, hostSerial) +} + func (s *DataStore) InsertMDMConfigAssets(ctx context.Context, assets []fleet.MDMConfigAsset) error { s.mu.Lock() s.InsertMDMConfigAssetsFuncInvoked = true @@ -5425,6 +5716,125 @@ func (s *DataStore) ReplaceMDMConfigAssets(ctx context.Context, assets []fleet.M return s.ReplaceMDMConfigAssetsFunc(ctx, assets) } +func (s *DataStore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { + s.mu.Lock() + s.GetABMTokenByOrgNameFuncInvoked = true + s.mu.Unlock() + return s.GetABMTokenByOrgNameFunc(ctx, orgName) +} + +func (s *DataStore) SaveABMToken(ctx context.Context, tok *fleet.ABMToken) error { + s.mu.Lock() + s.SaveABMTokenFuncInvoked = true + s.mu.Unlock() + return s.SaveABMTokenFunc(ctx, tok) +} + +func (s *DataStore) InsertVPPToken(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.InsertVPPTokenFuncInvoked = true + s.mu.Unlock() + return s.InsertVPPTokenFunc(ctx, tok) +} + +func (s *DataStore) ListVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.ListVPPTokensFuncInvoked = true + s.mu.Unlock() + return s.ListVPPTokensFunc(ctx) +} + +func (s *DataStore) GetVPPToken(ctx context.Context, tokenID uint) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.GetVPPTokenFuncInvoked = true + s.mu.Unlock() + return s.GetVPPTokenFunc(ctx, tokenID) +} + +func (s *DataStore) GetVPPTokenByTeamID(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.GetVPPTokenByTeamIDFuncInvoked = true + s.mu.Unlock() + return s.GetVPPTokenByTeamIDFunc(ctx, teamID) +} + +func (s *DataStore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []uint) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.UpdateVPPTokenTeamsFuncInvoked = true + s.mu.Unlock() + return s.UpdateVPPTokenTeamsFunc(ctx, id, teams) +} + +func (s *DataStore) UpdateVPPToken(ctx context.Context, id uint, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.UpdateVPPTokenFuncInvoked = true + s.mu.Unlock() + return s.UpdateVPPTokenFunc(ctx, id, tok) +} + +func (s *DataStore) DeleteVPPToken(ctx context.Context, tokenID uint) error { + s.mu.Lock() + s.DeleteVPPTokenFuncInvoked = true + s.mu.Unlock() + return s.DeleteVPPTokenFunc(ctx, tokenID) +} + +func (s *DataStore) SetABMTokenTermsExpiredForOrgName(ctx context.Context, orgName string, expired bool) (wasSet bool, err error) { + s.mu.Lock() + s.SetABMTokenTermsExpiredForOrgNameFuncInvoked = true + s.mu.Unlock() + return s.SetABMTokenTermsExpiredForOrgNameFunc(ctx, orgName, expired) +} + +func (s *DataStore) CountABMTokensWithTermsExpired(ctx context.Context) (int, error) { + s.mu.Lock() + s.CountABMTokensWithTermsExpiredFuncInvoked = true + s.mu.Unlock() + return s.CountABMTokensWithTermsExpiredFunc(ctx) +} + +func (s *DataStore) InsertABMToken(ctx context.Context, tok *fleet.ABMToken) (*fleet.ABMToken, error) { + s.mu.Lock() + s.InsertABMTokenFuncInvoked = true + s.mu.Unlock() + return s.InsertABMTokenFunc(ctx, tok) +} + +func (s *DataStore) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error) { + s.mu.Lock() + s.ListABMTokensFuncInvoked = true + s.mu.Unlock() + return s.ListABMTokensFunc(ctx) +} + +func (s *DataStore) DeleteABMToken(ctx context.Context, tokenID uint) error { + s.mu.Lock() + s.DeleteABMTokenFuncInvoked = true + s.mu.Unlock() + return s.DeleteABMTokenFunc(ctx, tokenID) +} + +func (s *DataStore) GetABMTokenByID(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) { + s.mu.Lock() + s.GetABMTokenByIDFuncInvoked = true + s.mu.Unlock() + return s.GetABMTokenByIDFunc(ctx, tokenID) +} + +func (s *DataStore) GetABMTokenCount(ctx context.Context) (int, error) { + s.mu.Lock() + s.GetABMTokenCountFuncInvoked = true + s.mu.Unlock() + return s.GetABMTokenCountFunc(ctx) +} + +func (s *DataStore) GetABMTokenOrgNamesAssociatedWithTeam(ctx context.Context, teamID *uint) ([]string, error) { + s.mu.Lock() + s.GetABMTokenOrgNamesAssociatedWithTeamFuncInvoked = true + s.mu.Unlock() + return s.GetABMTokenOrgNamesAssociatedWithTeamFunc(ctx, teamID) +} + func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error { s.mu.Lock() s.WSTEPStoreCertificateFuncInvoked = true @@ -5642,7 +6052,7 @@ func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp f return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp) } -func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { +func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BatchSetMDMProfilesFuncInvoked = true s.mu.Unlock() @@ -5670,7 +6080,7 @@ func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request * return s.NewHostScriptExecutionRequestFunc(ctx, request) } -func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) { +func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (hsr *fleet.HostScriptResult, action string, err error) { s.mu.Lock() s.SetHostScriptExecutionResultFuncInvoked = true s.mu.Unlock() @@ -5712,6 +6122,13 @@ func (s *DataStore) GetScriptContents(ctx context.Context, id uint) ([]byte, err return s.GetScriptContentsFunc(ctx, id) } +func (s *DataStore) GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error) { + s.mu.Lock() + s.GetAnyScriptContentsFuncInvoked = true + s.mu.Unlock() + return s.GetAnyScriptContentsFunc(ctx, id) +} + func (s *DataStore) DeleteScript(ctx context.Context, id uint) error { s.mu.Lock() s.DeleteScriptFuncInvoked = true @@ -5831,6 +6248,13 @@ func (s *DataStore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint return s.ListPendingSoftwareInstallsFunc(ctx, hostID) } +func (s *DataStore) GetHostLastInstallData(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { + s.mu.Lock() + s.GetHostLastInstallDataFuncInvoked = true + s.mu.Unlock() + return s.GetHostLastInstallDataFunc(ctx, hostID, installerID) +} + func (s *DataStore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { s.mu.Lock() s.MatchOrCreateSoftwareInstallerFuncInvoked = true @@ -5845,6 +6269,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByID(ctx context.Context, id uin return s.GetSoftwareInstallerMetadataByIDFunc(ctx, id) } +func (s *DataStore) ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) { + s.mu.Lock() + s.ValidateOrbitSoftwareInstallerAccessFuncInvoked = true + s.mu.Unlock() + return s.ValidateOrbitSoftwareInstallerAccessFunc(ctx, hostID, installerID) +} + func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { s.mu.Lock() s.GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked = true @@ -5852,11 +6283,46 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } -func (s *DataStore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.VPPApp, error) { +func (s *DataStore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + s.mu.Lock() + s.GetSoftwareInstallersWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallersWithoutPackageIDsFunc(ctx) +} + +func (s *DataStore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error { + s.mu.Lock() + s.UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.UpdateSoftwareInstallerWithoutPackageIDsFunc(ctx, id, payload) +} + +func (s *DataStore) ProcessInstallerUpdateSideEffects(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error { + s.mu.Lock() + s.ProcessInstallerUpdateSideEffectsFuncInvoked = true + s.mu.Unlock() + return s.ProcessInstallerUpdateSideEffectsFunc(ctx, installerID, wasMetadataUpdated, wasPackageUpdated) +} + +func (s *DataStore) SaveInstallerUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { + s.mu.Lock() + s.SaveInstallerUpdatesFuncInvoked = true + s.mu.Unlock() + return s.SaveInstallerUpdatesFunc(ctx, payload) +} + +func (s *DataStore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error { + s.mu.Lock() + s.UpdateInstallerSelfServiceFlagFuncInvoked = true + s.mu.Unlock() + return s.UpdateInstallerSelfServiceFlagFunc(ctx, selfService, id) +} + +func (s *DataStore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { s.mu.Lock() s.GetVPPAppByTeamAndTitleIDFuncInvoked = true s.mu.Unlock() - return s.GetVPPAppByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) + return s.GetVPPAppByTeamAndTitleIDFunc(ctx, teamID, titleID) } func (s *DataStore) GetVPPAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) { @@ -5901,11 +6367,11 @@ func (s *DataStore) GetSoftwareInstallResults(ctx context.Context, resultsUUID s return s.GetSoftwareInstallResultsFunc(ctx, resultsUUID) } -func (s *DataStore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore) error { +func (s *DataStore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error { s.mu.Lock() s.CleanupUnusedSoftwareInstallersFuncInvoked = true s.mu.Unlock() - return s.CleanupUnusedSoftwareInstallersFunc(ctx, softwareInstallStore) + return s.CleanupUnusedSoftwareInstallersFunc(ctx, softwareInstallStore, removeCreatedBefore) } func (s *DataStore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { @@ -5915,6 +6381,13 @@ func (s *DataStore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, return s.BatchSetSoftwareInstallersFunc(ctx, tmID, installers) } +func (s *DataStore) GetSoftwareInstallers(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { + s.mu.Lock() + s.GetSoftwareInstallersFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallersFunc(ctx, tmID) +} + func (s *DataStore) HasSelfServiceSoftwareInstallers(ctx context.Context, platform string, teamID *uint) (bool, error) { s.mu.Lock() s.HasSelfServiceSoftwareInstallersFuncInvoked = true @@ -5929,14 +6402,14 @@ func (s *DataStore) BatchInsertVPPApps(ctx context.Context, apps []*fleet.VPPApp return s.BatchInsertVPPAppsFunc(ctx, apps) } -func (s *DataStore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]struct{}, error) { +func (s *DataStore) GetAssignedVPPApps(ctx context.Context, teamID *uint) (map[fleet.VPPAppID]fleet.VPPAppTeam, error) { s.mu.Lock() s.GetAssignedVPPAppsFuncInvoked = true s.mu.Unlock() return s.GetAssignedVPPAppsFunc(ctx, teamID) } -func (s *DataStore) SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []fleet.VPPAppID) error { +func (s *DataStore) SetTeamVPPApps(ctx context.Context, teamID *uint, appIDs []fleet.VPPAppTeam) error { s.mu.Lock() s.SetTeamVPPAppsFuncInvoked = true s.mu.Unlock() @@ -5950,11 +6423,11 @@ func (s *DataStore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, return s.InsertVPPAppWithTeamFunc(ctx, app, teamID) } -func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, userID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string) error { +func (s *DataStore) InsertHostVPPSoftwareInstall(ctx context.Context, hostID uint, appID fleet.VPPAppID, commandUUID string, associatedEventID string, selfService bool) error { s.mu.Lock() s.InsertHostVPPSoftwareInstallFuncInvoked = true s.mu.Unlock() - return s.InsertHostVPPSoftwareInstallFunc(ctx, hostID, userID, appID, commandUUID, associatedEventID) + return s.InsertHostVPPSoftwareInstallFunc(ctx, hostID, appID, commandUUID, associatedEventID, selfService) } func (s *DataStore) GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityInstalledAppStoreApp, error) { @@ -5963,3 +6436,10 @@ func (s *DataStore) GetPastActivityDataForVPPAppInstall(ctx context.Context, com s.mu.Unlock() return s.GetPastActivityDataForVPPAppInstallFunc(ctx, commandResults) } + +func (s *DataStore) GetVPPTokenByLocation(ctx context.Context, loc string) (*fleet.VPPTokenDB, error) { + s.mu.Lock() + s.GetVPPTokenByLocationFuncInvoked = true + s.mu.Unlock() + return s.GetVPPTokenByLocationFunc(ctx, loc) +} diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go index 05d9db687d..78141b2525 100644 --- a/server/mock/mdm/datastore_mdm_mock.go +++ b/server/mock/mdm/datastore_mdm_mock.go @@ -56,6 +56,8 @@ type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, err type GetAllMDMConfigAssetsByNameFunc func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) +type GetABMTokenByOrgNameFunc func(ctx context.Context, orgName string) (*fleet.ABMToken, error) + 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 @@ -124,6 +126,9 @@ type MDMAppleStore struct { GetAllMDMConfigAssetsByNameFunc GetAllMDMConfigAssetsByNameFunc GetAllMDMConfigAssetsByNameFuncInvoked bool + GetABMTokenByOrgNameFunc GetABMTokenByOrgNameFunc + GetABMTokenByOrgNameFuncInvoked bool + EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFuncInvoked bool @@ -280,6 +285,13 @@ func (fs *MDMAppleStore) GetAllMDMConfigAssetsByName(ctx context.Context, assetN return fs.GetAllMDMConfigAssetsByNameFunc(ctx, assetNames) } +func (fs *MDMAppleStore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { + fs.mu.Lock() + fs.GetABMTokenByOrgNameFuncInvoked = true + fs.mu.Unlock() + return fs.GetABMTokenByOrgNameFunc(ctx, orgName) +} + func (fs *MDMAppleStore) EnqueueDeviceLockCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { fs.mu.Lock() fs.EnqueueDeviceLockCommandFuncInvoked = true diff --git a/server/service/activities.go b/server/service/activities.go index cdab1837f1..861c873095 100644 --- a/server/service/activities.go +++ b/server/service/activities.go @@ -85,7 +85,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 04079821f7..17b83dcbbf 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -25,6 +25,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/version" "github.com/go-kit/log/level" + "golang.org/x/text/unicode/norm" ) //////////////////////////////////////////////////////////////////////////////// @@ -413,6 +414,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, ctxerr.Wrap(ctx, err, "validating MDM config") } + abmAssignments, err := svc.validateABMAssignments(ctx, &newAppConfig.MDM, &oldAppConfig.MDM, invalid, license) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "validating ABM token assignments") + } + + vppAssignments, err := svc.validateVPPAssignments(ctx, &newAppConfig.MDM, invalid, license) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "validating VPP token assignments") + } + if invalid.HasErrors() { return nil, ctxerr.Wrap(ctx, invalid) } @@ -534,6 +545,27 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" { + for _, tok := range abmAssignments { + fmt.Println(tok.EncryptedToken) + if err := svc.ds.SaveABMToken(ctx, tok); err != nil { + return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") + } + } + } + + if appConfig.MDM.VolumePurchasingProgram.Set && appConfig.MDM.VolumePurchasingProgram.Valid { + for tokenID, tokenTeams := range vppAssignments { + if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil { + var errTokConstraint fleet.ErrVPPTokenTeamConstraint + if errors.As(err, &errTokConstraint) { + return nil, ctxerr.Wrap(ctx, fleet.NewUserMessageError(errTokConstraint, http.StatusConflict)) + } + return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") + } + } + } + // retrieve new app config with obfuscated secrets obfuscatedAppConfig, err := svc.ds.AppConfig(ctx) if err != nil { @@ -808,21 +840,11 @@ func (svc *Service) validateMDM( len(mdm.WindowsSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { invalid.Append("windows_settings.custom_settings", - `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. This can be enabled by setting "controls.windows_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/windows-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`) } } checkCustomSettings("windows", mdm.WindowsSettings.CustomSettings.Value) - if name := mdm.AppleBMDefaultTeam; name != "" && name != oldMdm.AppleBMDefaultTeam { - if !license.IsPremium() { - invalid.Append("mdm.apple_bm_default_team", ErrMissingLicense.Error()) - return nil - } - if _, err := svc.ds.TeamByName(ctx, name); err != nil { - invalid.Append("apple_bm_default_team", "team name not found") - } - } - // MacOSUpdates updatingMacOSVersion := mdm.MacOSUpdates.MinimumVersion.Value != "" && mdm.MacOSUpdates.MinimumVersion != oldMdm.MacOSUpdates.MinimumVersion @@ -947,6 +969,182 @@ func (svc *Service) validateMDM( return nil } +func (svc *Service) validateABMAssignments( + ctx context.Context, + mdm, oldMdm *fleet.MDM, + invalid *fleet.InvalidArgumentError, + license *fleet.LicenseInfo, +) ([]*fleet.ABMToken, error) { + if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid { + invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage) + return nil, nil + } + + if name := mdm.DeprecatedAppleBMDefaultTeam; name != "" && name != oldMdm.DeprecatedAppleBMDefaultTeam { + if !license.IsPremium() { + invalid.Append("mdm.apple_bm_default_team", ErrMissingLicense.Error()) + return nil, nil + } + team, err := svc.ds.TeamByName(ctx, name) + if err != nil { + invalid.Append("mdm.apple_bm_default_team", "team name not found") + return nil, nil + } + tokens, err := svc.ds.ListABMTokens(ctx) + if err != nil { + return nil, err + } + + if len(tokens) > 1 { + invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage) + return nil, nil + } + + if len(tokens) == 0 { + invalid.Append("mdm.apple_bm_default_team", "no ABM tokens found") + return nil, nil + } + + tok := tokens[0] + tok.MacOSDefaultTeamID = &team.ID + tok.IOSDefaultTeamID = &team.ID + tok.IPadOSDefaultTeamID = &team.ID + + return []*fleet.ABMToken{tok}, nil + } + + if mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid { + if !license.IsPremium() { + invalid.Append("mdm.apple_business_manager", ErrMissingLicense.Error()) + return nil, nil + } + + teams, err := svc.ds.TeamsSummary(ctx) + if err != nil { + return nil, err + } + teamsByName := map[string]*uint{"": nil, "No team": nil} + for _, tm := range teams { + teamsByName[tm.Name] = &tm.ID + } + tokens, err := svc.ds.ListABMTokens(ctx) + if err != nil { + return nil, err + } + tokensByName := map[string]*fleet.ABMToken{} + for _, token := range tokens { + // The default assignments for all tokens is "no team" + // (ie: team_id IS NULL), here we reset the assignments + // for all tokens, those will be re-added below. + // + // This ensures any unassignments are properly handled. + token.MacOSDefaultTeamID = nil + token.IOSDefaultTeamID = nil + token.IPadOSDefaultTeamID = nil + tokensByName[token.OrganizationName] = token + } + + var tokensToSave []*fleet.ABMToken + for _, bm := range mdm.AppleBusinessManager.Value { + for _, tmName := range []string{bm.MacOSTeam, bm.IOSTeam, bm.IpadOSTeam} { + if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok { + invalid.Appendf("mdm.apple_business_manager", "team %s doesn't exist", tmName) + return nil, nil + } + } + + if _, ok := tokensByName[norm.NFC.String(bm.OrganizationName)]; !ok { + invalid.Appendf("mdm.apple_business_manager", "token with organization name %s doesn't exist", bm.OrganizationName) + return nil, nil + } + + tok := tokensByName[bm.OrganizationName] + tok.MacOSDefaultTeamID = teamsByName[bm.MacOSTeam] + tok.IOSDefaultTeamID = teamsByName[bm.IOSTeam] + tok.IPadOSDefaultTeamID = teamsByName[bm.IpadOSTeam] + tokensToSave = append(tokensToSave, tok) + } + + return tokensToSave, nil + } + + return nil, nil +} + +func (svc *Service) validateVPPAssignments( + ctx context.Context, + mdm *fleet.MDM, + invalid *fleet.InvalidArgumentError, + license *fleet.LicenseInfo, +) (map[uint][]uint, error) { + if mdm.VolumePurchasingProgram.Set && mdm.VolumePurchasingProgram.Valid { + if !license.IsPremium() { + invalid.Append("mdm.volume_purchasing_program", ErrMissingLicense.Error()) + return nil, nil + } + + teams, err := svc.ds.TeamsSummary(ctx) + if err != nil { + return nil, err + } + teamsByName := map[string]uint{fleet.TeamNameNoTeam: 0} + for _, tm := range teams { + teamsByName[tm.Name] = tm.ID + } + tokens, err := svc.ds.ListVPPTokens(ctx) + if err != nil { + return nil, err + } + tokensByLocation := map[string]*fleet.VPPTokenDB{} + for _, token := range tokens { + // The default assignments for all tokens is "no team" + // (ie: team_id IS NULL), here we reset the assignments + // for all tokens, those will be re-added below. + // + // This ensures any unassignments are properly handled. + tokensByLocation[token.Location] = token + token.Teams = nil + } + + tokensToSave := make(map[uint][]uint, len(mdm.VolumePurchasingProgram.Value)) + for _, vpp := range mdm.VolumePurchasingProgram.Value { + for _, tmName := range vpp.Teams { + if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok && tmName != fleet.TeamNameAllTeams { + invalid.Appendf("mdm.volume_purchasing_program", "team %s doesn't exist", tmName) + return nil, nil + } + } + + loc := norm.NFC.String(vpp.Location) + if _, ok := tokensByLocation[loc]; !ok { + invalid.Appendf("mdm.volume_purchasing_program", "token with location %s doesn't exist", vpp.Location) + return nil, nil + } + + var tokenTeams []uint + for _, teamName := range vpp.Teams { + if teamName == fleet.TeamNameAllTeams { + if len(vpp.Teams) > 1 { + invalid.Appendf("mdm.volume_purchasing_program", "token cannot belong to %s and other teams", fleet.TeamNameAllTeams) + return nil, nil + } + tokenTeams = []uint{} + break + } + teamID := teamsByName[teamName] + tokenTeams = append(tokenTeams, teamID) + } + + tok := tokensByLocation[loc] + tokensToSave[tok.ID] = tokenTeams + } + + return tokensToSave, nil + } + + return nil, nil +} + func validateSSOProviderSettings(incoming, existing fleet.SSOProviderSettings, invalid *fleet.InvalidArgumentError) { if incoming.Metadata == "" && incoming.MetadataURL == "" { if existing.Metadata == "" && existing.MetadataURL == "" { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 8a2905458d..0fb0d318d2 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -858,11 +858,13 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -870,31 +872,33 @@ func TestMDMAppleConfig(t *testing.T) { }, { name: "newDefaultTeamNoLicense", licenseTier: "free", - newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, + newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedError: licenseErr, }, { name: "notFoundNew", licenseTier: "premium", - newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, + newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedError: notFoundErr, }, { name: "notFoundEdit", licenseTier: "premium", - oldMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, - newMDM: fleet.MDM{AppleBMDefaultTeam: "bar"}, + oldMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, + newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "bar"}, expectedError: notFoundErr, }, { name: "foundNew", licenseTier: "premium", findTeam: true, - newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, + newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + DeprecatedAppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -903,15 +907,17 @@ func TestMDMAppleConfig(t *testing.T) { name: "foundEdit", licenseTier: "premium", findTeam: true, - oldMDM: fleet.MDM{AppleBMDefaultTeam: "bar"}, - newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, + oldMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "bar"}, + newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ - AppleBMDefaultTeam: "foobar", - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + DeprecatedAppleBMDefaultTeam: "foobar", + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -929,12 +935,14 @@ func TestMDMAppleConfig(t *testing.T) { newMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, expectedMDM: fleet.MDM{ - EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -950,17 +958,19 @@ func TestMDMAppleConfig(t *testing.T) { IDPName: "onelogin", }}}, expectedMDM: fleet.MDM{ + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{ EntityID: "fleet", IssuerURI: "http://issuer.idp.com", MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -1015,12 +1025,14 @@ func TestMDMAppleConfig(t *testing.T) { EnableDiskEncryption: optjson.SetBool(false), }, expectedMDM: fleet.MDM{ - EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -1063,6 +1075,12 @@ func TestMDMAppleConfig(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + ds.SaveABMTokenFunc = func(ctx context.Context, token *fleet.ABMToken) error { + return nil + } depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) { return &nanodep_client.Config{BaseURL: depSrv.URL}, nil } diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 3f1ef747a3..4387367ef0 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -31,11 +31,11 @@ import ( mdm_types "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest" + "github.com/fleetdm/fleet/v4/server/mdm/apple/gdmf" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/assets" mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto" mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" - "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" @@ -44,6 +44,7 @@ import ( "github.com/go-kit/log/level" "github.com/google/uuid" "github.com/groob/plist" + "go.mozilla.org/pkcs7" ) type getMDMAppleCommandResultsRequest struct { @@ -380,7 +381,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r } return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -470,7 +471,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r i return nil, err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } @@ -773,7 +774,7 @@ func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID return ctxerr.Wrap(ctx, err) } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -853,7 +854,7 @@ func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID stri return ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1154,45 +1155,6 @@ func (svc *Service) ListMDMAppleDevices(ctx context.Context) ([]fleet.MDMAppleDe return svc.ds.MDMAppleListDevices(ctx) } -type listMDMAppleDEPDevicesRequest struct{} - -type listMDMAppleDEPDevicesResponse struct { - Devices []fleet.MDMAppleDEPDevice `json:"devices"` - Err error `json:"error,omitempty"` -} - -func (r listMDMAppleDEPDevicesResponse) error() error { return r.Err } - -func listMDMAppleDEPDevicesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - devices, err := svc.ListMDMAppleDEPDevices(ctx) - if err != nil { - return listMDMAppleDEPDevicesResponse{Err: err}, nil - } - return &listMDMAppleDEPDevicesResponse{ - Devices: devices, - }, nil -} - -func (svc *Service) ListMDMAppleDEPDevices(ctx context.Context) ([]fleet.MDMAppleDEPDevice, error) { - if err := svc.authz.Authorize(ctx, &fleet.MDMAppleDEPDevice{}, fleet.ActionWrite); err != nil { - return nil, ctxerr.Wrap(ctx, err) - } - depClient := apple_mdm.NewDEPClient(svc.depStorage, svc.ds, svc.logger) - - // TODO(lucas): Use cursors and limit to fetch in multiple requests. - // This single-request version supports up to 1000 devices (max to return in one call). - fetchDevicesResponse, err := depClient.FetchDevices(ctx, apple_mdm.DEPName, godep.WithLimit(1000)) - if err != nil { - return nil, ctxerr.Wrap(ctx, err) - } - - devices := make([]fleet.MDMAppleDEPDevice, len(fetchDevicesResponse.Devices)) - for i := range fetchDevicesResponse.Devices { - devices[i] = fleet.MDMAppleDEPDevice{Device: fetchDevicesResponse.Devices[i]} - } - return devices, nil -} - type newMDMAppleDEPKeyPairResponse struct { PublicKey []byte `json:"public_key,omitempty"` PrivateKey []byte `json:"private_key,omitempty"` @@ -1287,6 +1249,38 @@ func (svc *Service) EnqueueMDMAppleCommand( type mdmAppleEnrollRequest struct { Token string `query:"token"` EnrollmentReference string `query:"enrollment_reference,optional"` + MachineInfo *fleet.MDMAppleMachineInfo +} + +func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := mdmAppleEnrollRequest{} + + tok := r.URL.Query().Get("token") + if tok == "" { + return nil, &fleet.BadRequestError{ + Message: "token is required", + } + } + decoded.Token = tok + + er := r.URL.Query().Get("enrollment_reference") + decoded.EnrollmentReference = er + + // Parse the machine info from the request body + di := r.Header.Get("x-apple-aspen-deviceinfo") + if di != "" { + // extract x-apple-aspen-deviceinfo custom header from request + parsed, err := apple_mdm.ParseDeviceinfo(di, false) // FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "unable to parse deviceinfo header", + InternalErr: err, + } + } + decoded.MachineInfo = parsed + } + + return &decoded, nil } func (r mdmAppleEnrollResponse) error() error { return r.Err } @@ -1296,9 +1290,20 @@ type mdmAppleEnrollResponse struct { // Profile field is used in hijackRender for the response. Profile []byte + + SoftwareUpdateRequired *fleet.MDMAppleSoftwareUpdateRequired } func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + if r.SoftwareUpdateRequired != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + if err := json.NewEncoder(w).Encode(r.SoftwareUpdateRequired); err != nil { + encodeError(ctx, ctxerr.New(ctx, "failed to encode software update required"), w) + } + return + } + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10)) w.Header().Set("Content-Type", "application/x-apple-aspen-config") w.Header().Set("X-Content-Type-Options", "nosniff") @@ -1316,6 +1321,16 @@ func (r mdmAppleEnrollResponse) hijackRender(ctx context.Context, w http.Respons func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*mdmAppleEnrollRequest) + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, req.MachineInfo) + if err != nil { + return mdmAppleEnrollResponse{Err: err}, nil + } + if sur != nil { + return mdmAppleEnrollResponse{ + SoftwareUpdateRequired: sur, + }, nil + } + profile, err := svc.GetMDMAppleEnrollmentProfileByToken(ctx, req.Token, req.EnrollmentReference) if err != nil { return mdmAppleEnrollResponse{Err: err}, nil @@ -1376,6 +1391,89 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok return signed, nil } +func (svc *Service) CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { + // skipauth: The enroll profile endpoint is unauthenticated. + svc.authz.SkipAuthorization(ctx) + + if m == nil { + level.Debug(svc.logger).Log("msg", "no machine info, skipping os version check") + return nil, nil + } + + level.Debug(svc.logger).Log("msg", "checking os version", "serial", m.Serial, "current_version", m.OSVersion) + + if !m.MDMCanRequestSoftwareUpdate { + level.Debug(svc.logger).Log("msg", "mdm cannot request software update, skipping os version check", "serial", m.Serial) + return nil, nil + } + + needsUpdate, err := svc.needsOSUpdateForDEPEnrollment(ctx, *m) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "checking os updates settings", "serial", m.Serial) + } + + if !needsUpdate { + level.Debug(svc.logger).Log("msg", "device is above minimum, skipping os version check", "serial", m.Serial) + return nil, nil + } + + sur, err := svc.getAppleSoftwareUpdateRequiredForDEPEnrollment(*m) + if err != nil { + // log for debugging but allow enrollment to proceed + level.Info(svc.logger).Log("msg", "getting apple software update required", "serial", m.Serial, "err", err) + return nil, nil + } + + return sur, nil +} + +func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.MDMAppleMachineInfo) (bool, error) { + // NOTE: Under the hood, the datastore is joining host_dep_assignments to the hosts table to + // look up DEP hosts by serial number. It grabs the team id and platform from the + // hosts table. Then it uses the team id to get either the global config or team config. + // Finally, it uses the platform to get os updates settings from the config for + // one of ios, ipados, or darwin, as applicable. There's a lot of assumptions going on here, not + // least of which is that the platform is correct in the hosts table. If the platform is wrong, + // we'll end up with a meaningless comparison of unrelated versions. We could potentially add + // some cross-check against the machine info to ensure that the platform of the host aligns with + // what we expect from the machine info. But that would involve work to derive the platform from + // the machine info (presumably from the product name, but that's not a 1:1 mapping). + settings, err := svc.ds.GetMDMAppleOSUpdatesSettingsByHostSerial(ctx, m.Serial) + if err != nil { + if fleet.IsNotFound(err) { + level.Info(svc.logger).Log("msg", "checking os updates settings, settings not found", "serial", m.Serial) + return false, nil + } + return false, err + } + // TODO: confirm what this check should do + if !settings.MinimumVersion.Set || !settings.MinimumVersion.Valid || settings.MinimumVersion.Value == "" { + level.Info(svc.logger).Log("msg", "checking os updates settings, minimum version not set", "serial", m.Serial, "current_version", m.OSVersion, "minimum_version", settings.MinimumVersion.Value) + return false, nil + } + + return apple_mdm.IsLessThanVersion(m.OSVersion, settings.MinimumVersion.Value) +} + +func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) { + latest, err := gdmf.GetLatestOSVersion(m) + if err != nil { + return nil, err + } + + needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, latest.ProductVersion) + if err != nil { + return nil, err + } else if !needsUpdate { + return nil, nil + } + + return fleet.NewMDMAppleSoftwareUpdateRequired(fleet.MDMAppleSoftwareUpdateAsset{ + ProductVersion: latest.ProductVersion, + Build: latest.Build, + }), nil +} + func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) { assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetAPNSCert, @@ -1472,47 +1570,17 @@ func (svc *Service) EnqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co return ctxerr.Wrap(ctx, err, "logging activity for mdm apple remove profile command") } - return svc.pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx, cmdUUID, h.UUID, info.Platform) -} - -func (svc *Service) pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, cmdUUID string, deviceID string, platform string) error { - ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(5*time.Second)) - ticker := time.NewTicker(300 * time.Millisecond) - defer func() { - ticker.Stop() - cancelFn() - }() - - for { - select { - case <-ctx.Done(): - // time out after 5 seconds - return fleet.MDMAppleCommandTimeoutError{} - case <-ticker.C: - nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, deviceID) - if err != nil { - level.Error(svc.logger).Log("err", "get nanomdm enrollment status", "details", err, "id", deviceID, "command_uuid", cmdUUID) - return err - } - if nanoEnroll != nil && nanoEnroll.Enabled { - // check again on the next tick - continue - } - // success, mdm enrollment is no longer enabled for the device - level.Info(svc.logger).Log("msg", "mdm disabled for device", "id", deviceID, "command_uuid", cmdUUID) - - mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) - err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ - Action: mdmlifecycle.HostActionTurnOff, - Platform: platform, - UUID: deviceID, - }) - if err != nil { - return err - } - return nil - } + mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) + err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionTurnOff, + Platform: info.Platform, + UUID: h.UUID, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "running turn off action in mdm lifecycle") } + + return nil } type mdmAppleGetInstallerRequest struct { @@ -1880,7 +1948,7 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } @@ -2716,7 +2784,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ // Check if this is a result of a "refetch" command sent to iPhones/iPads // to fetch their device information periodically. - if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCommandUUIDPrefix) { + if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchBaseCommandUUIDPrefix) { return svc.handleRefetch(r, cmdResult) } @@ -2793,6 +2861,15 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe } if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) { + // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. + err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchAppsCommandUUIDPrefix, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command") + } + if host.Platform != "ios" && host.Platform != "ipados" { return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host") } @@ -2814,6 +2891,16 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe return nil, nil } + // Otherwise, the command has prefix fleet.RefetchDeviceCommandUUIDPrefix, which is a refetch device command. + // We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch. + err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchDeviceCommandUUIDPrefix, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "remove refetch device command") + } + var deviceInformationResponse struct { QueryResponses map[string]interface{} `plist:"QueryResponses"` } @@ -2860,11 +2947,20 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe }); err != nil { return nil, ctxerr.Wrap(r.Context, err, "failed to update host operating system") } + + if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Pending" { + // Since the device has been refetched, we can assume it's enrolled. + err = svc.ds.UpdateMDMData(ctx, host.ID, true) + if err != nil { + return nil, ctxerr.Wrap(r.Context, err, "failed to update MDM data") + } + } return nil, nil } func unmarshalAppList(ctx context.Context, response []byte, source string) ([]fleet.Software, - error) { + error, +) { var appsResponse struct { InstalledApplicationList []map[string]interface{} `plist:"InstalledApplicationList"` } @@ -3023,7 +3119,7 @@ func SendPushesToPendingDevices( if err := commander.SendNotifications(ctx, uuids); err != nil { var apnsErr *apple_mdm.APNSDeliveryError if errors.As(err, &apnsErr) { - level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "host_uuids", apnsErr.FailedUUIDs) + level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "error", apnsErr.Error()) return nil } @@ -3202,8 +3298,8 @@ func ReconcileAppleProfiles( continue } - if p.FailedToInstallOnHost() { - // then we shouldn't send an additional remove command since it failed to install on the + if p.DidNotInstallOnHost() { + // then we shouldn't send an additional remove command since it wasn't installed on the // host. hostProfilesToCleanup = append(hostProfilesToCleanup, p) continue @@ -3463,9 +3559,13 @@ func RenewSCEPCertificates( } } - migrationEnrollmentProfile := os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE") + decodedMigrationEnrollmentProfile, err := base64.StdEncoding.DecodeString(os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE")) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to decode silent migration enrollment profile") + } hasAssocsFromMigration := len(assocsFromMigration) > 0 + migrationEnrollmentProfile := string(decodedMigrationEnrollmentProfile) if migrationEnrollmentProfile == "" && hasAssocsFromMigration { level.Debug(logger).Log("msg", "found devices from migration that need SCEP renewals but FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE is empty") } @@ -3847,7 +3947,8 @@ func (uploadABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) } type uploadABMTokenResponse struct { - Err error `json:"error,omitempty"` + Token *fleet.ABMToken `json:"abm_token,omitempty"` + Err error `json:"error,omitempty"` } func (r uploadABMTokenResponse) error() error { return r.Err } @@ -3860,125 +3961,412 @@ func uploadABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet. } defer ff.Close() - if err := svc.SaveABMToken(ctx, ff); err != nil { + token, err := svc.UploadABMToken(ctx, ff) + if err != nil { return uploadABMTokenResponse{ Err: err, }, nil } - return uploadABMTokenResponse{}, nil + return uploadABMTokenResponse{Token: token}, nil } -func (svc *Service) SaveABMToken(ctx context.Context, token io.Reader) error { - // first check for reads as we need to load the cert/key from the db. We will - // do another write check below. - if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionRead); err != nil { - return err - } +func (svc *Service) UploadABMToken(ctx context.Context, token io.Reader) (*fleet.ABMToken, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) - pair, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetABMCert, - fleet.MDMAssetABMKey, - }) - if err != nil { - if fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: "Please generate a keypair first.", - }, "saving ABM token") - } - - return ctxerr.Wrap(ctx, err, "retrieving stored ABM assets") - } - - tokenBytes, err := io.ReadAll(token) - if err != nil { - return ctxerr.Wrap(ctx, err, "reading token bytes") - } - - derCert, _ := pem.Decode(pair[fleet.MDMAssetABMCert].Value) - if derCert == nil { - return ctxerr.Wrap(ctx, err, "ABM certificate in the database cannot be parsed") - } - - cert, err := x509.ParseCertificate(derCert.Bytes) - if err != nil { - return ctxerr.Wrap(ctx, err, "parsing ABM certificate") - } - - if _, err := assets.DecryptRawABMToken(tokenBytes, cert, pair[fleet.MDMAssetABMKey].Value); err != nil { - return ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: "Invalid token. Please provide a valid token from Apple Business Manager.", - InternalErr: err, - }, "validating ABM token") - } - - if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionWrite); err != nil { - return err - } - - // delete the old token and insert the new one - // TODO(roberto): replacing the token should be done in a single transaction in the DB - err = svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetABMToken}) - if err != nil { - return ctxerr.Wrap(ctx, err, "deleting old ABM token in database") - } - err = svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetABMToken, Value: tokenBytes}, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "saving ABM token in database") - } - - // flip the app config flag - appCfg, err := svc.ds.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "retrieving app config") - } - - appCfg.MDM.AppleBMEnabledAndConfigured = true - - return svc.ds.SaveAppConfig(ctx, appCfg) + return nil, fleet.ErrMissingLicense } //////////////////////////////////////////////////////////////////////////////// // Disable ABM endpoint //////////////////////////////////////////////////////////////////////////////// -type disableABMResponse struct { +type deleteABMTokenRequest struct { + TokenID uint `url:"id"` +} + +type deleteABMTokenResponse struct { Err error `json:"error,omitempty"` } -func (r disableABMResponse) error() error { return r.Err } -func (r disableABMResponse) Status() int { return http.StatusNoContent } +func (r deleteABMTokenResponse) error() error { return r.Err } +func (r deleteABMTokenResponse) Status() int { return http.StatusNoContent } -func disableABMEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - if err := svc.DisableABM(ctx); err != nil { - return disableABMResponse{Err: err}, nil +func deleteABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*deleteABMTokenRequest) + if err := svc.DeleteABMToken(ctx, req.TokenID); err != nil { + return deleteABMTokenResponse{Err: err}, nil } - return disableABMResponse{}, nil + return deleteABMTokenResponse{}, nil } -func (svc *Service) DisableABM(ctx context.Context) error { - if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionWrite); err != nil { - return err +func (svc *Service) DeleteABMToken(ctx context.Context, tokenID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// List ABM tokens endpoint +//////////////////////////////////////////////////////////////////////////////// + +type listABMTokensResponse struct { + Err error `json:"error,omitempty"` + Tokens []*fleet.ABMToken `json:"abm_tokens"` +} + +func (r listABMTokensResponse) error() error { return r.Err } + +func listABMTokensEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + tokens, err := svc.ListABMTokens(ctx) + if err != nil { + return &listABMTokensResponse{Err: err}, nil } - err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetABMCert, - fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, + if tokens == nil { + tokens = []*fleet.ABMToken{} + } + + return &listABMTokensResponse{Tokens: tokens}, nil +} + +func (svc *Service) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Update ABM token teams endpoint +//////////////////////////////////////////////////////////////////////////////// + +type updateABMTokenTeamsRequest struct { + TokenID uint `url:"id"` + MacOSTeamID *uint `json:"macos_team_id"` + IOSTeamID *uint `json:"ios_team_id"` + IPadOSTeamID *uint `json:"ipados_team_id"` +} + +type updateABMTokenTeamsResponse struct { + ABMToken *fleet.ABMToken `json:"abm_token,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r updateABMTokenTeamsResponse) error() error { return r.Err } + +func updateABMTokenTeamsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*updateABMTokenTeamsRequest) + + tok, err := svc.UpdateABMTokenTeams(ctx, req.TokenID, req.MacOSTeamID, req.IOSTeamID, req.IPadOSTeamID) + if err != nil { + return &updateABMTokenTeamsResponse{Err: err}, nil + } + + return &updateABMTokenTeamsResponse{ABMToken: tok}, nil +} + +func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID *uint) (*fleet.ABMToken, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// Renew ABM token endpoint +//////////////////////////////////////////////////////////////////////////////// + +type renewABMTokenRequest struct { + TokenID uint `url:"id"` + Token *multipart.FileHeader +} + +func (renewABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + token, ok := r.MultipartForm.File["token"] + if !ok || len(token) < 1 { + return nil, &fleet.BadRequestError{Message: "no file headers for token"} + } + + // because we are in this method, we know that the path has 7 parts, e.g: + // /api/latest/fleet/abm_tokens/19/renew + + id, err := intFromRequest(r, "id") + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to parse abm token id") + } + + return &renewABMTokenRequest{ + Token: token[0], + TokenID: uint(id), + }, nil +} + +type renewABMTokenResponse struct { + ABMToken *fleet.ABMToken `json:"abm_token,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r renewABMTokenResponse) error() error { return r.Err } + +func renewABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*renewABMTokenRequest) + ff, err := req.Token.Open() + if err != nil { + return &renewABMTokenResponse{Err: err}, nil + } + defer ff.Close() + + tok, err := svc.RenewABMToken(ctx, ff, req.TokenID) + if err != nil { + return &renewABMTokenResponse{Err: err}, nil + } + + return &renewABMTokenResponse{ABMToken: tok}, nil +} + +func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID uint) (*fleet.ABMToken, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// GET /enrollment_profiles/ota +//////////////////////////////////////////////////////////////////////////////// + +type getOTAProfileRequest struct { + EnrollSecret string `query:"enroll_secret"` +} + +func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getOTAProfileRequest) + profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret) + if err != nil { + return &getMDMAppleConfigProfileResponse{Err: err}, err + } + + reader := bytes.NewReader(profile) + return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil +} + +func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) { + // Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done + // by the enroll secret verification below + svc.authz.SkipAuthorization(ctx) + + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name") + } + + profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file") + } + + signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// POST /ota_enrollment?enroll_secret=xyz +//////////////////////////////////////////////////////////////////////////////// + +type mdmAppleOTARequest struct { + EnrollSecret string `query:"enroll_secret"` + Certificates []*x509.Certificate + RootSigner *x509.Certificate + DeviceInfo fleet.MDMAppleMachineInfo +} + +func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + enrollSecret := r.URL.Query().Get("enroll_secret") + if enrollSecret == "" { + return nil, &fleet.OTAForbiddenError{ + InternalErr: errors.New("enroll_secret query parameter was empty"), + } + } + + rawData, err := io.ReadAll(r.Body) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading body from request") + } + + p7, err := pkcs7.Parse(rawData) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + var request mdmAppleOTARequest + err = plist.Unmarshal(p7.Content, &request.DeviceInfo) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "invalid request body", + InternalErr: err, + } + } + + if request.DeviceInfo.Serial == "" { + return nil, &fleet.BadRequestError{ + Message: "SERIAL is required", + } + } + + request.EnrollSecret = enrollSecret + request.Certificates = p7.Certificates + request.RootSigner = p7.GetOnlySigner() + return &request, nil +} + +type mdmAppleOTAResponse struct { + Err error `json:"error,omitempty"` + xml []byte +} + +func (r mdmAppleOTAResponse) error() error { return r.Err } + +func (r mdmAppleOTAResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.xml))) + w.Header().Set("Content-Type", "application/x-apple-aspen-config") + w.Header().Set("X-Content-Type-Options", "nosniff") + if _, err := w.Write(r.xml); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func mdmAppleOTAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*mdmAppleOTARequest) + xml, err := svc.MDMAppleProcessOTAEnrollment(ctx, req.Certificates, req.RootSigner, req.EnrollSecret, req.DeviceInfo) + if err != nil { + return mdmAppleGetInstallerResponse{Err: err}, nil + } + return mdmAppleOTAResponse{xml: xml}, nil +} + +// NOTE: this method and how OTA works is documented in full in the interface definition. +func (svc *Service) MDMAppleProcessOTAEnrollment( + ctx context.Context, + certificates []*x509.Certificate, + rootSigner *x509.Certificate, + enrollSecret string, + deviceInfo fleet.MDMAppleMachineInfo, +) ([]byte, error) { + // authorization is performed via the enroll secret and the provided certificates + svc.authz.SkipAuthorization(ctx) + + if len(certificates) == 0 { + return nil, authz.ForbiddenWithInternal("no certificates provided", nil, nil, nil) + } + + // first check is for the enroll secret, we'll only let the host + // through if it has a valid secret. + enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret) + if err != nil { + if fleet.IsNotFound(err) { + return nil, &fleet.OTAForbiddenError{ + InternalErr: err, + } + } + + return nil, ctxerr.Wrap(ctx, err, "validating enroll secret") + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetSCEPChallenge, }) if err != nil { - return ctxerr.Wrap(ctx, err, "disabling ABM config") + return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) } + scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value) - // flip the app config flag appCfg, err := svc.ds.AppConfig(ctx) if err != nil { - return ctxerr.Wrap(ctx, err, "retrieving app config") + return nil, ctxerr.Wrap(ctx, err, "reading app config") + } + fleetURL := appCfg.ServerSettings.ServerURL + + // if the root signer was issued by Apple's CA, it means we're in the + // first phase and we should return a SCEP payload. + if err := apple_mdm.VerifyFromAppleIphoneDeviceCA(rootSigner); err == nil { + scepURL, err := apple_mdm.ResolveAppleSCEPURL(fleetURL) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "resolve Apple SCEP url") + } + + var buf bytes.Buffer + if err := apple_mdm.OTASCEPTemplate.Execute(&buf, struct { + SCEPURL string + SCEPChallenge string + }{ + SCEPURL: scepURL, + SCEPChallenge: scepChallenge, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "execute template") + } + return buf.Bytes(), nil } - appCfg.MDM.AppleBMEnabledAndConfigured = false - return svc.ds.SaveAppConfig(ctx, appCfg) + // otherwise we might be in the second phase, check if the signing cert + // was issued by Fleet, only let the enrollment through if so. + certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds) + if err := certVerifier.Verify(rootSigner); err != nil { + return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil) + } + + topic, err := svc.mdmPushCertTopic(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") + } + + enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig( + appCfg.OrgInfo.OrgName, + appCfg.ServerSettings.ServerURL, + string(assets[fleet.MDMAssetSCEPChallenge].Value), + topic, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile") + } + + // before responding, create a host record, and assign the host to the + // team that matches the enroll secret provided. + err = svc.ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, enrollSecretInfo.TeamID, deviceInfo) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating new host record") + } + + // at this point we know the device can be enrolled, so we respond with + // a signed enrollment profile + signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 545de31a0a..aeabb3542b 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -24,6 +24,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -48,9 +49,9 @@ import ( "github.com/google/uuid" "github.com/groob/plist" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) type nopProfileMatcher struct{} @@ -79,6 +80,9 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi _, err := w.Write([]byte(`{"auth_session_token": "yoo"}`)) require.NoError(t, err) return + case strings.Contains(r.URL.Path, "/profile"): + _, err := w.Write([]byte(`{"profile_uuid": "profile123"}`)) + require.NoError(t, err) } })) @@ -217,12 +221,32 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi }, nil } + ds.GetABMTokenOrgNamesAssociatedWithTeamFunc = func(ctx context.Context, teamID *uint) ([]string, error) { + return []string{"foobar"}, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + return svc, ctx, ds } func TestAppleMDMAuthorization(t *testing.T) { svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + ds.GetEnrollSecretsFunc = func(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) { + return []*fleet.EnrollSecret{ + { + Secret: "abcd", + TeamID: nil, + }, + { + Secret: "efgh", + TeamID: nil, + }, + }, nil + } + checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) { t.Helper() @@ -246,8 +270,6 @@ func TestAppleMDMAuthorization(t *testing.T) { checkAuthErr(t, err, shouldFailWithAuth) _, err = svc.ListMDMAppleDevices(ctx) checkAuthErr(t, err, shouldFailWithAuth) - _, err = svc.ListMDMAppleDEPDevices(ctx) - checkAuthErr(t, err, shouldFailWithAuth) // check EULA routes _, err = svc.MDMGetEULAMetadata(ctx) @@ -600,8 +622,9 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) { ds.GetMDMAppleProfilesSummaryFunc = func(context.Context, *uint) (*fleet.MDMProfilesSummary, error) { return nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } mockGetFuncWithTeamID := func(teamID uint) mock.GetMDMAppleConfigProfileFunc { return func(ctx context.Context, puid string) (*fleet.MDMAppleConfigProfile, error) { @@ -707,8 +730,9 @@ func TestNewMDMAppleConfigProfile(t *testing.T) { ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, false) @@ -1500,8 +1524,9 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil @@ -1816,8 +1841,9 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil @@ -2592,7 +2618,7 @@ func TestEnsureFleetdConfig(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { appCfg := &fleet.AppConfig{} appCfg.ServerSettings.ServerURL = testURL - appCfg.MDM.AppleBMDefaultTeam = testTeamName + appCfg.MDM.DeprecatedAppleBMDefaultTeam = testTeamName return appCfg, nil } ds.AggregateEnrollSecretPerTeamFunc = func(ctx context.Context) ([]*fleet.EnrollSecret, error) { @@ -2636,7 +2662,7 @@ func TestEnsureFleetdConfig(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { appCfg := &fleet.AppConfig{} appCfg.ServerSettings.ServerURL = testURL - appCfg.MDM.AppleBMDefaultTeam = testTeamName + appCfg.MDM.DeprecatedAppleBMDefaultTeam = testTeamName return appCfg, nil } ds.AggregateEnrollSecretPerTeamFunc = func(ctx context.Context) ([]*fleet.EnrollSecret, error) { @@ -2743,6 +2769,12 @@ func TestMDMAppleSetupAssistant(t *testing.T) { ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { return &fleet.Team{ID: id}, nil } + ds.GetMDMAppleEnrollmentProfileByTypeFunc = func(ctx context.Context, typ fleet.MDMAppleEnrollmentType) (*fleet.MDMAppleEnrollmentProfile, error) { + return &fleet.MDMAppleEnrollmentProfile{Token: "foobar"}, nil + } + ds.CountABMTokensWithTermsExpiredFunc = func(ctx context.Context) (int, error) { + return 0, nil + } testCases := []struct { name string @@ -3211,7 +3243,7 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) { ctx := context.Background() hostID := uint(42) hostUUID := "ABC-DEF-GHI" - commandUUID := "REFETCH-COMMAND-UUID" + commandUUID := fleet.RefetchDeviceCommandUUIDPrefix + "UUID" ds := new(mock.Store) svc := MDMAppleCheckinAndCommandService{ds: ds} @@ -3245,6 +3277,11 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) { require.Equal(t, "ipados", hostOS.Platform) return nil } + ds.RemoveHostMDMCommandFunc = func(ctx context.Context, command fleet.HostMDMCommand) error { + assert.Equal(t, hostID, command.HostID) + assert.Equal(t, fleet.RefetchDeviceCommandUUIDPrefix, command.CommandType) + return nil + } _, err := svc.CommandAndReportResults( &mdm.Request{Context: ctx}, @@ -3286,6 +3323,7 @@ func TestMDMCommandAndReportResultsIOSIPadOSRefetch(t *testing.T) { require.True(t, ds.HostByIdentifierFuncInvoked) require.True(t, ds.SetOrUpdateHostDisksSpaceFuncInvoked) require.True(t, ds.UpdateHostOperatingSystemFuncInvoked) + assert.True(t, ds.RemoveHostMDMCommandFuncInvoked) } func TestUnmarshalAppList(t *testing.T) { @@ -3371,3 +3409,246 @@ func TestUnmarshalAppList(t *testing.T) { require.NoError(t, err) assert.ElementsMatch(t, expectedSoftware, software) } + +func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { + svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + + gdmf := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + // load the test data from the file + b, err := os.ReadFile("../mdm/apple/gdmf/testdata/gdmf.json") + require.NoError(t, err) + _, err = w.Write(b) + require.NoError(t, err) + })) + defer gdmf.Close() + t.Setenv("FLEET_DEV_GDMF_URL", gdmf.URL) + + latestMacOSVersion := "14.6.1" + latestMacOSBuild := "23G93" + + testCases := []struct { + name string + machineInfo *fleet.MDMAppleMachineInfo + updateRequired *fleet.MDMAppleSoftwareUpdateRequiredDetails + err string + }{ + { + name: "OS version is greater than latest", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + Product: "Mac15,7", + OSVersion: "14.6.2", + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "J516sAP", + }, + updateRequired: nil, + }, + { + name: "OS version is equal to latest", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + Product: "Mac15,7", + OSVersion: latestMacOSVersion, + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "J516sAP", + }, + updateRequired: nil, + }, + { + name: "OS version is less than latest", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + Product: "Mac15,7", + OSVersion: "14.4", + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "J516sAP", + }, + updateRequired: &fleet.MDMAppleSoftwareUpdateRequiredDetails{ + OSVersion: latestMacOSVersion, + BuildVersion: latestMacOSBuild, + }, + }, + { + name: "OS version is less than latest but MDM cannot request software update", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: false, + Product: "Mac15,7", + OSVersion: "14.4", + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "J516sAP", + }, + updateRequired: nil, + }, + { + name: "no match for software update device ID", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + Product: "Mac15,7", + OSVersion: "14.4", + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "INVALID", + }, + updateRequired: nil, + err: "", // no error, allow enrollment to proceed without software update + }, + { + name: "no machine info", + machineInfo: nil, + updateRequired: nil, + err: "", // no error, allow enrollment to proceed without software update + }, + { + name: "cannot parse OS version", + machineInfo: &fleet.MDMAppleMachineInfo{ + MDMCanRequestSoftwareUpdate: true, + Product: "Mac15,7", + OSVersion: "INVALID", + SupplementalBuildVersion: "IRRELEVANT", + SoftwareUpdateDeviceID: "J516sAP", + }, + updateRequired: nil, + err: "invalid current version", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + t.Run("settings minimum equal to latest", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(latestMacOSVersion), + }, nil + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + if tt.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.err) + } else { + require.NoError(t, err) + } + if tt.updateRequired != nil { + require.Equal(t, &fleet.MDMAppleSoftwareUpdateRequired{ + Code: fleet.MDMAppleSoftwareUpdateRequiredCode, + Details: *tt.updateRequired, + }, sur) + } else { + require.Nil(t, sur) + } + }) + + t.Run("settings minimum below latest", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.5"), + }, nil + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + if tt.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.err) + } else { + require.NoError(t, err) + } + if tt.updateRequired != nil { + require.Equal(t, &fleet.MDMAppleSoftwareUpdateRequired{ + Code: fleet.MDMAppleSoftwareUpdateRequiredCode, + Details: *tt.updateRequired, + }, sur) + } else { + require.Nil(t, sur) + } + }) + + t.Run("settings minimum above latest", func(t *testing.T) { + // edge case, but in practice it would get treated as if minimum was equal to latest + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.7"), + }, nil + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + if tt.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.err) + } else { + require.NoError(t, err) + } + if tt.updateRequired != nil { + require.Equal(t, &fleet.MDMAppleSoftwareUpdateRequired{ + Code: fleet.MDMAppleSoftwareUpdateRequiredCode, + Details: *tt.updateRequired, + }, sur) + } else { + require.Nil(t, sur) + } + }) + + t.Run("device above settings minimum", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.1"), + }, nil + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + if tt.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.err) + } else { + require.NoError(t, err) + } + + require.Nil(t, sur) + }) + + t.Run("minimum not set", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{}, nil + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + require.NoError(t, err) + require.Nil(t, sur) + + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(""), + }, nil + } + sur, err = svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + require.NoError(t, err) + require.Nil(t, sur) + }) + + t.Run("minimum not found", func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return nil, ¬FoundError{} + } + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + require.NoError(t, err) + require.Nil(t, sur) + }) + }) + } + + t.Run("gdmf server is down", func(t *testing.T) { + gdmf.Close() + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ds.GetMDMAppleOSUpdatesSettingsByHostSerialFunc = func(ctx context.Context, serial string) (*fleet.AppleOSUpdateSettings, error) { + return &fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString(latestMacOSVersion)}, nil + } + + sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, tt.machineInfo) + if tt.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.err) // still can get errors parsing the versions from the device info header or config settings + } else { + require.NoError(t, err) + } + + require.Nil(t, sur) // if gdmf server is down, we don't enforce os updates for DEP + }) + } + }) +} diff --git a/server/service/client.go b/server/service/client.go index 09ab08e031..8924707784 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" + "github.com/fleetdm/fleet/v4/server/ptr" kithttp "github.com/go-kit/kit/transport/http" ) @@ -394,8 +395,11 @@ func (c *Client) ApplyGroup( specs *spec.Group, baseDir string, logf func(format string, args ...interface{}), + appconfig *fleet.EnrichedAppConfig, opts fleet.ApplyClientSpecOptions, -) (map[string]uint, error) { +) (map[string]uint, map[string][]fleet.SoftwarePackageResponse, error) { + teamSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse) + logfn := func(format string, args ...interface{}) { if logf != nil { logf(format, args...) @@ -408,7 +412,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyQueries(specs.Queries); err != nil { - return nil, fmt.Errorf("applying queries: %w", err) + return nil, nil, fmt.Errorf("applying queries: %w", err) } logfn("[+] applied %d queries\n", len(specs.Queries)) } @@ -419,42 +423,18 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyLabels(specs.Labels); err != nil { - return nil, fmt.Errorf("applying labels: %w", err) + return nil, nil, fmt.Errorf("applying labels: %w", err) } logfn("[+] applied %d labels\n", len(specs.Labels)) } } - if len(specs.Policies) > 0 { - if opts.DryRun { - logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n") - } else { - // Policy names must be unique, return error if duplicate policy names are found - if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { - return nil, fmt.Errorf( - "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, - ) - } - - // If set, override the team in all the policies. - if opts.TeamForPolicies != "" { - for _, policySpec := range specs.Policies { - policySpec.Team = opts.TeamForPolicies - } - } - if err := c.ApplyPolicies(specs.Policies); err != nil { - return nil, fmt.Errorf("applying policies: %w", err) - } - logfn("[+] applied %d policies\n", len(specs.Policies)) - } - } - if len(specs.Packs) > 0 { if opts.DryRun { logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyPacks(specs.Packs); err != nil { - return nil, fmt.Errorf("applying packs: %w", err) + return nil, nil, fmt.Errorf("applying packs: %w", err) } logfn("[+] applied %d packs\n", len(specs.Packs)) } @@ -474,7 +454,7 @@ func (c *Client) ApplyGroup( if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(windowsCustomSettings)+len(macosCustomSettings) > 0 { fileContents, err := getProfilesContents(baseDir, macosCustomSettings, windowsCustomSettings, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, err + return nil, nil, err } // Figure out if MDM should be enabled. assumeEnabled := false @@ -488,30 +468,30 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamProfiles(fileContents, opts.ApplySpecOptions, assumeEnabled); err != nil { - return nil, fmt.Errorf("applying custom settings: %w", err) + return nil, nil, fmt.Errorf("applying custom settings: %w", err) } } if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil { if macosSetup.BootstrapPackage.Value != "" { pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } if macosSetup.MacOSSetupAssistant.Value != "" { content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value)) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } @@ -522,7 +502,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -530,11 +510,11 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamScripts(scriptPayloads, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying custom settings: %w", err) + return nil, nil, fmt.Errorf("applying custom settings: %w", err) } } if err := c.ApplyAppConfig(specs.AppConfig, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if opts.DryRun { logfn("[+] would've applied fleet config\n") @@ -545,7 +525,7 @@ func (c *Client) ApplyGroup( if specs.EnrollSecret != nil { if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying enroll secrets: %w", err) + return nil, nil, fmt.Errorf("applying enroll secrets: %w", err) } if opts.DryRun { logfn("[+] would've applied enroll secrets\n") @@ -564,7 +544,7 @@ func (c *Client) ApplyGroup( for k, profileSpecs := range tmMDMSettings { fileContents, err := getProfilesContents(baseDir, profileSpecs.macos, profileSpecs.windows, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once + return nil, nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once } tmFileContents[k] = fileContents } @@ -576,14 +556,14 @@ func (c *Client) ApplyGroup( if setup.BootstrapPackage.Value != "" { bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } tmBootstrapPackages[k] = bp } if setup.MacOSSetupAssistant.Value != "" { b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value)) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } tmMacSetupAssistants[k] = b } @@ -597,7 +577,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -608,92 +588,12 @@ func (c *Client) ApplyGroup( } tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams) - tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) + tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmSoftwarePackages)) for tmName, software := range tmSoftwarePackages { - softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(software)) - for i, si := range software { - var qc string - var err error - if si.PreInstallQuery.Path != "" { - queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path) - rawSpec, err := os.ReadFile(queryFile) - if err != nil { - return nil, fmt.Errorf("reading pre-install query: %w", err) - } - - rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec) - if err != nil { - return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) - } - - var top any - - if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil { - return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) - } - - if _, ok := top.(map[any]any); ok { - // Old apply format - group, err := spec.GroupFromBytes(rawSpecExpanded) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err) - } - - if len(group.Queries) > 1 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) - } - - if len(group.Queries) == 0 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) - } - - qc = group.Queries[0].Query - } else { - // Gitops format - var querySpecs []fleet.QuerySpec - if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err) - } - - if len(querySpecs) > 1 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) - } - - if len(querySpecs) == 0 { - return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) - } - - qc = querySpecs[0].Query - } - } - - var ic []byte - if si.InstallScript.Path != "" { - installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path) - ic, err = os.ReadFile(installScriptFile) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err) - } - } - - var pc []byte - if si.PostInstallScript.Path != "" { - postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path) - pc, err = os.ReadFile(postInstallScriptFile) - if err != nil { - return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err) - } - } - - softwarePayloads[i] = fleet.SoftwareInstallerPayload{ - URL: si.URL, - SelfService: si.SelfService, - PreInstallQuery: qc, - InstallScript: string(ic), - PostInstallScript: string(pc), - } + softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software) + if err != nil { + return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } - tmSoftwarePackagesPayloads[tmName] = softwarePayloads } @@ -702,7 +602,7 @@ func (c *Client) ApplyGroup( for tmName, apps := range tmSoftwareApps { appPayloads := make([]fleet.VPPBatchPayload, 0, len(apps)) for _, app := range apps { - appPayloads = append(appPayloads, fleet.VPPBatchPayload{AppStoreID: app.AppStoreID}) + appPayloads = append(appPayloads, fleet.VPPBatchPayload{AppStoreID: app.AppStoreID, SelfService: app.SelfService}) } tmSoftwareAppsPayloads[tmName] = appPayloads } @@ -717,7 +617,7 @@ func (c *Client) ApplyGroup( // In dry-run, the team names returned are the old team names (when team name is modified via gitops) teamIDsByName, err = c.ApplyTeams(specs.Teams, teamOpts) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } // When using GitOps, the team name could change, so we need to check for that @@ -744,7 +644,7 @@ func (c *Client) ApplyGroup( } else { logfn("[+] applying MDM profiles for team %s\n", tmName) if err := c.ApplyTeamProfiles(currentTeamName, profs, teamOpts); err != nil { - return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) } } } @@ -753,12 +653,23 @@ func (c *Client) ApplyGroup( for tmName, tmID := range teamIDsByName { if bp, ok := tmBootstrapPackages[tmName]; ok { if err := c.EnsureBootstrapPackage(bp, tmID); err != nil { - return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) } } if b, ok := tmMacSetupAssistants[tmName]; ok { if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil { - return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) + if strings.Contains(err.Error(), "Couldn't upload") { + // Then the error should look something like this: + // "Couldn't upload. CONFIG_NAME_INVALID" + // We want the part after the period (this is the error name from Apple) + // to render a more helpful error message. + parts := strings.Split(err.Error(), ".") + if len(parts) < 2 { + return nil, nil, fmt.Errorf("unexpected error while uploading macOS setup assistant for team %q: %w", tmName, err) + } + return nil, nil, fmt.Errorf("Couldn't edit macos_setup_assistant. Response from Apple: %s. Learn more at %s", strings.Trim(parts[1], " "), "https://fleetdm.com/learn-more-about/dep-profile") + } + return nil, nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) } } } @@ -768,7 +679,7 @@ func (c *Client) ApplyGroup( // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamScripts(currentTeamName, scripts, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) } } } @@ -776,9 +687,12 @@ func (c *Client) ApplyGroup( for tmName, software := range tmSoftwarePackagesPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) - if err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) + logfn("[+] applying %d software packages for team %s\n", len(software), tmName) + installers, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions) + if err != nil { + return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } + teamSoftwareInstallers[tmName] = installers } } if len(tmSoftwareAppsPayloads) > 0 { @@ -786,7 +700,7 @@ func (c *Client) ApplyGroup( // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) } } } @@ -797,17 +711,142 @@ func (c *Client) ApplyGroup( } } + // Policies can reference software installers thus they are applied at this point. + if len(specs.Policies) > 0 { + // Policy names must be unique, return error if duplicate policy names are found + if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { + return nil, nil, fmt.Errorf( + "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, + ) + } + if opts.DryRun { + logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n") + } else { + // If set, override the team in all the policies. + if opts.TeamForPolicies != "" { + for _, policySpec := range specs.Policies { + policySpec.Team = opts.TeamForPolicies + } + } + if err := c.ApplyPolicies(specs.Policies); err != nil { + return nil, nil, fmt.Errorf("applying policies: %w", err) + } + logfn("[+] applied %d policies\n", len(specs.Policies)) + } + } + if specs.UsersRoles != nil { if opts.DryRun { logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil { - return nil, fmt.Errorf("applying user roles: %w", err) + return nil, nil, fmt.Errorf("applying user roles: %w", err) } logfn("[+] applied user roles\n") } } - return teamIDsByName, nil + + return teamIDsByName, teamSoftwareInstallers, nil +} + +func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) { + softwarePayloads := make([]fleet.SoftwareInstallerPayload, len(specs)) + for i, si := range specs { + var qc string + var err error + if si.PreInstallQuery.Path != "" { + queryFile := resolveApplyRelativePath(baseDir, si.PreInstallQuery.Path) + rawSpec, err := os.ReadFile(queryFile) + if err != nil { + return nil, fmt.Errorf("reading pre-install query: %w", err) + } + + rawSpecExpanded, err := spec.ExpandEnvBytes(rawSpec) + if err != nil { + return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) + } + + var top any + + if err := yaml.Unmarshal(rawSpecExpanded, &top); err != nil { + return nil, fmt.Errorf("Couldn't exit software (%s). Unable to expand environment variable in YAML file %s: %w", si.URL, queryFile, err) + } + + if _, ok := top.(map[any]any); ok { + // Old apply format + group, err := spec.GroupFromBytes(rawSpecExpanded) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install apply format query YAML file %s: %w", si.URL, queryFile, err) + } + + if len(group.Queries) > 1 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) + } + + if len(group.Queries) == 0 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) + } + + qc = group.Queries[0].Query + } else { + // Gitops format + var querySpecs []fleet.QuerySpec + if err := yaml.Unmarshal(rawSpecExpanded, &querySpecs); err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to parse pre-install query YAML file %s: %w", si.URL, queryFile, err) + } + + if len(querySpecs) > 1 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s should have only one query.", si.URL, queryFile) + } + + if len(querySpecs) == 0 { + return nil, fmt.Errorf("Couldn't edit software (%s). Pre-install query YAML file %s doesn't have a query defined.", si.URL, queryFile) + } + + qc = querySpecs[0].Query + } + } + + var ic []byte + if si.InstallScript.Path != "" { + installScriptFile := resolveApplyRelativePath(baseDir, si.InstallScript.Path) + ic, err = os.ReadFile(installScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read install script file %s: %w", si.URL, si.InstallScript.Path, err) + } + } + + var pc []byte + if si.PostInstallScript.Path != "" { + postInstallScriptFile := resolveApplyRelativePath(baseDir, si.PostInstallScript.Path) + pc, err = os.ReadFile(postInstallScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read post-install script file %s: %w", si.URL, si.PostInstallScript.Path, err) + } + } + + var us []byte + if si.UninstallScript.Path != "" { + uninstallScriptFile := resolveApplyRelativePath(baseDir, si.UninstallScript.Path) + us, err = os.ReadFile(uninstallScriptFile) + if err != nil { + return nil, fmt.Errorf("Couldn't edit software (%s). Unable to read uninstall script file %s: %w", si.URL, + si.UninstallScript.Path, err) + } + } + + softwarePayloads[i] = fleet.SoftwareInstallerPayload{ + URL: si.URL, + SelfService: si.SelfService, + PreInstallQuery: qc, + InstallScript: string(ic), + PostInstallScript: string(pc), + UninstallScript: string(us), + } + + } + + return softwarePayloads, nil } func extractAppCfgMacOSSetup(appCfg any) *fleet.MacOSSetup { @@ -1045,8 +1084,8 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string]profi return m } -func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.TeamSpecSoftwarePackage { - var m map[string][]fleet.TeamSpecSoftwarePackage +func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]fleet.SoftwarePackageSpec { + var m map[string][]fleet.SoftwarePackageSpec for _, tm := range tmSpecs { var spec struct { Name string `json:"name"` @@ -1059,10 +1098,10 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee spec.Name = norm.NFC.String(spec.Name) if spec.Name != "" && len(spec.Software) > 0 { if m == nil { - m = make(map[string][]fleet.TeamSpecSoftwarePackage) + m = make(map[string][]fleet.SoftwarePackageSpec) } - var software fleet.TeamSpecSoftware - var packages []fleet.TeamSpecSoftwarePackage + var software fleet.SoftwareSpec + var packages []fleet.SoftwarePackageSpec if err := json.Unmarshal(spec.Software, &software); err != nil { // ignore, will fail in apply team specs call continue @@ -1070,7 +1109,7 @@ func extractTmSpecsSoftwarePackages(tmSpecs []json.RawMessage) map[string][]flee if !software.Packages.Valid { // to be consistent with the AppConfig custom settings, set it to an // empty slice if the provided custom settings are present but empty. - packages = []fleet.TeamSpecSoftwarePackage{} + packages = []fleet.SoftwarePackageSpec{} } else { packages = software.Packages.Value } @@ -1096,7 +1135,7 @@ func extractTmSpecsSoftwareApps(tmSpecs []json.RawMessage) map[string][]fleet.Te if m == nil { m = make(map[string][]fleet.TeamSpecAppStoreApp) } - var software fleet.TeamSpecSoftware + var software fleet.SoftwareSpec var apps []fleet.TeamSpecAppStoreApp if err := json.Unmarshal(spec.Software, &software); err != nil { // ignore, will fail in apply team specs call @@ -1255,7 +1294,7 @@ func (c *Client) DoGitOps( } } group.AppConfig.(map[string]interface{})["scripts"] = scripts - } else { + } else if !config.IsNoTeam() { team = make(map[string]interface{}) team["name"] = *config.TeamName team["agent_options"] = config.AgentOptions @@ -1273,9 +1312,13 @@ func (c *Client) DoGitOps( 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 _, ok := webhookSettings.(map[string]interface{}); ok { + if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { + clearHostStatusWebhook = false + team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + } + } else if webhookSettings != nil { + return nil, fmt.Errorf("team_settings.webhook_settings config is not a map but a %T", webhookSettings) } } if clearHostStatusWebhook { @@ -1305,115 +1348,119 @@ func (c *Client) DoGitOps( team["mdm"] = map[string]interface{}{} mdmAppConfig = team["mdm"].(map[string]interface{}) } - // Common controls settings between org and team settings - // Put in default values for macos_settings - if config.Controls.MacOSSettings != nil { - mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings - } else { - mdmAppConfig["macos_settings"] = map[string]interface{}{} - } - macOSSettings := mdmAppConfig["macos_settings"].(map[string]interface{}) - if customSettings, ok := macOSSettings["custom_settings"]; !ok || customSettings == nil { - macOSSettings["custom_settings"] = []interface{}{} - } - // Put in default values for macos_updates - if config.Controls.MacOSUpdates != nil { - mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates - } else { - mdmAppConfig["macos_updates"] = map[string]interface{}{} - } - macOSUpdates := mdmAppConfig["macos_updates"].(map[string]interface{}) - if minimumVersion, ok := macOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - macOSUpdates["minimum_version"] = "" - } - if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { - macOSUpdates["deadline"] = "" - } - // Put in default values for ios_updates - if config.Controls.IOSUpdates != nil { - mdmAppConfig["ios_updates"] = config.Controls.IOSUpdates - } else { - mdmAppConfig["ios_updates"] = map[string]interface{}{} - } - iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{}) - if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - iOSUpdates["minimum_version"] = "" - } - if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { - iOSUpdates["deadline"] = "" - } - // Put in default values for ipados_updates - if config.Controls.IPadOSUpdates != nil { - mdmAppConfig["ipados_updates"] = config.Controls.IPadOSUpdates - } else { - mdmAppConfig["ipados_updates"] = map[string]interface{}{} - } - iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{}) - if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil { - iPadOSUpdates["minimum_version"] = "" - } - if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { - iPadOSUpdates["deadline"] = "" - } - // Put in default values for macos_setup - if config.Controls.MacOSSetup != nil { - mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup - } else { - mdmAppConfig["macos_setup"] = map[string]interface{}{} - } - macOSSetup := mdmAppConfig["macos_setup"].(map[string]interface{}) - if bootstrapPackage, ok := macOSSetup["bootstrap_package"]; !ok || bootstrapPackage == nil { - macOSSetup["bootstrap_package"] = "" - } - if enableEndUserAuthentication, ok := macOSSetup["enable_end_user_authentication"]; !ok || enableEndUserAuthentication == nil { - macOSSetup["enable_end_user_authentication"] = false - } - if macOSSetupAssistant, ok := macOSSetup["macos_setup_assistant"]; !ok || macOSSetupAssistant == nil { - macOSSetup["macos_setup_assistant"] = "" - } - // Put in default values for windows_settings - if config.Controls.WindowsSettings != nil { - mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings - } else { - mdmAppConfig["windows_settings"] = map[string]interface{}{} - } - windowsSettings := mdmAppConfig["windows_settings"].(map[string]interface{}) - if customSettings, ok := windowsSettings["custom_settings"]; !ok || customSettings == nil { - windowsSettings["custom_settings"] = []interface{}{} - } - // Put in default values for windows_updates - if config.Controls.WindowsUpdates != nil { - mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates - } else { - mdmAppConfig["windows_updates"] = map[string]interface{}{} - } - if appConfig.License.IsPremium() { - windowsUpdates := mdmAppConfig["windows_updates"].(map[string]interface{}) - if deadlineDays, ok := windowsUpdates["deadline_days"]; !ok || deadlineDays == nil { - windowsUpdates["deadline_days"] = nil + + if !config.IsNoTeam() { + // Common controls settings between org and team settings + // Put in default values for macos_settings + if config.Controls.MacOSSettings != nil { + mdmAppConfig["macos_settings"] = config.Controls.MacOSSettings + } else { + mdmAppConfig["macos_settings"] = map[string]interface{}{} } - if gracePeriodDays, ok := windowsUpdates["grace_period_days"]; !ok || gracePeriodDays == nil { - windowsUpdates["grace_period_days"] = nil + macOSSettings := mdmAppConfig["macos_settings"].(map[string]interface{}) + if customSettings, ok := macOSSettings["custom_settings"]; !ok || customSettings == nil { + macOSSettings["custom_settings"] = []interface{}{} } - } - // Put in default value for enable_disk_encryption - if config.Controls.EnableDiskEncryption != nil { - mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption - } else { - mdmAppConfig["enable_disk_encryption"] = false - } - if config.TeamName != nil { - team["gitops_filename"] = filename - rawTeam, err := json.Marshal(team) - if err != nil { - return nil, fmt.Errorf("error marshalling team spec: %w", err) + // Put in default values for macos_updates + if config.Controls.MacOSUpdates != nil { + mdmAppConfig["macos_updates"] = config.Controls.MacOSUpdates + } else { + mdmAppConfig["macos_updates"] = map[string]interface{}{} + } + macOSUpdates := mdmAppConfig["macos_updates"].(map[string]interface{}) + if minimumVersion, ok := macOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + macOSUpdates["minimum_version"] = "" + } + if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { + macOSUpdates["deadline"] = "" + } + // Put in default values for ios_updates + if config.Controls.IOSUpdates != nil { + mdmAppConfig["ios_updates"] = config.Controls.IOSUpdates + } else { + mdmAppConfig["ios_updates"] = map[string]interface{}{} + } + iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{}) + if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iOSUpdates["minimum_version"] = "" + } + if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { + iOSUpdates["deadline"] = "" + } + // Put in default values for ipados_updates + if config.Controls.IPadOSUpdates != nil { + mdmAppConfig["ipados_updates"] = config.Controls.IPadOSUpdates + } else { + mdmAppConfig["ipados_updates"] = map[string]interface{}{} + } + iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{}) + if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iPadOSUpdates["minimum_version"] = "" + } + if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { + iPadOSUpdates["deadline"] = "" + } + // Put in default values for macos_setup + if config.Controls.MacOSSetup != nil { + mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup + } else { + mdmAppConfig["macos_setup"] = map[string]interface{}{} + } + macOSSetup := mdmAppConfig["macos_setup"].(map[string]interface{}) + if bootstrapPackage, ok := macOSSetup["bootstrap_package"]; !ok || bootstrapPackage == nil { + macOSSetup["bootstrap_package"] = "" + } + if enableEndUserAuthentication, ok := macOSSetup["enable_end_user_authentication"]; !ok || enableEndUserAuthentication == nil { + macOSSetup["enable_end_user_authentication"] = false + } + if macOSSetupAssistant, ok := macOSSetup["macos_setup_assistant"]; !ok || macOSSetupAssistant == nil { + macOSSetup["macos_setup_assistant"] = "" + } + // Put in default values for windows_settings + if config.Controls.WindowsSettings != nil { + mdmAppConfig["windows_settings"] = config.Controls.WindowsSettings + } else { + mdmAppConfig["windows_settings"] = map[string]interface{}{} + } + windowsSettings := mdmAppConfig["windows_settings"].(map[string]interface{}) + if customSettings, ok := windowsSettings["custom_settings"]; !ok || customSettings == nil { + windowsSettings["custom_settings"] = []interface{}{} + } + // Put in default values for windows_updates + if config.Controls.WindowsUpdates != nil { + mdmAppConfig["windows_updates"] = config.Controls.WindowsUpdates + } else { + mdmAppConfig["windows_updates"] = map[string]interface{}{} + } + if appConfig.License.IsPremium() { + windowsUpdates := mdmAppConfig["windows_updates"].(map[string]interface{}) + if deadlineDays, ok := windowsUpdates["deadline_days"]; !ok || deadlineDays == nil { + windowsUpdates["deadline_days"] = nil + } + if gracePeriodDays, ok := windowsUpdates["grace_period_days"]; !ok || gracePeriodDays == nil { + windowsUpdates["grace_period_days"] = nil + } + } + // Put in default value for enable_disk_encryption + if config.Controls.EnableDiskEncryption != nil { + mdmAppConfig["enable_disk_encryption"] = config.Controls.EnableDiskEncryption + } else { + mdmAppConfig["enable_disk_encryption"] = false + } + + if config.TeamName != nil { + team["gitops_filename"] = filename + rawTeam, err := json.Marshal(team) + if err != nil { + return nil, fmt.Errorf("error marshalling team spec: %w", err) + } + group.Teams = []json.RawMessage{rawTeam} + group.TeamsDryRunAssumptions = teamDryRunAssumptions } - group.Teams = []json.RawMessage{rawTeam} - group.TeamsDryRunAssumptions = teamDryRunAssumptions } - // Apply org settings, scripts, enroll secrets, and controls - teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, fleet.ApplyClientSpecOptions{ + // Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls. + teamIDsByName, teamsSoftwareInstallers, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ ApplySpecOptions: fleet.ApplySpecOptions{ DryRun: dryRun, }, @@ -1422,42 +1469,127 @@ func (c *Client) DoGitOps( if err != nil { return nil, err } + + var teamSoftwareInstallers []fleet.SoftwarePackageResponse if config.TeamName != nil { - if len(teamIDsByName) != 1 { - return nil, fmt.Errorf("expected 1 team spec to be applied, got %d", len(teamIDsByName)) - } - teamID, ok := teamIDsByName[*config.TeamName] - if ok && teamID == 0 { - if dryRun { - logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName) - return nil, nil + if !config.IsNoTeam() { + if len(teamIDsByName) != 1 { + return nil, fmt.Errorf("expected 1 team spec to be applied, got %d", len(teamIDsByName)) } - return nil, fmt.Errorf("team %s not created", *config.TeamName) - } - for _, teamID = range teamIDsByName { - config.TeamID = &teamID + teamID, ok := teamIDsByName[*config.TeamName] + if ok && teamID == 0 { + if dryRun { + logFn("[+] would've added any policies/queries to new team %s\n", *config.TeamName) + return nil, nil + } + return nil, fmt.Errorf("team %s not created", *config.TeamName) + } + for _, teamID = range teamIDsByName { + config.TeamID = &teamID + } + teamSoftwareInstallers = teamsSoftwareInstallers[*config.TeamName] + } else { + noTeamSoftwareInstallers, err := c.doGitOpsNoTeamSoftware(config, baseDir, appConfig, logFn, dryRun) + if err != nil { + return nil, err + } + teamSoftwareInstallers = noTeamSoftwareInstallers } } - err = c.doGitOpsPolicies(config, logFn, dryRun) + err = c.doGitOpsPolicies(config, teamSoftwareInstallers, logFn, dryRun) if err != nil { return nil, err } - err = c.doGitOpsQueries(config, logFn, dryRun) - if err != nil { - return nil, err + // We currently don't support queries for "No team" thus + // we just do GitOps for queries for global and team files. + if !config.IsNoTeam() { + err = c.doGitOpsQueries(config, logFn, dryRun) + if err != nil { + return nil, err + } } return teamAssumptions, nil } -func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { +func (c *Client) doGitOpsNoTeamSoftware(config *spec.GitOps, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwarePackageResponse, error) { + var softwareInstallers []fleet.SoftwarePackageResponse + if config.IsNoTeam() && appconfig != nil && appconfig.License.IsPremium() { + packages := make([]fleet.SoftwarePackageSpec, 0, len(config.Software.Packages)) + for _, software := range config.Software.Packages { + if software != nil { + packages = append(packages, *software) + } + } + payload, err := buildSoftwarePackagesPayload(baseDir, packages) + if err != nil { + return nil, fmt.Errorf("applying software installers: %w", err) + } + logFn("[+] applying %d software packages for 'No team'\n", len(payload)) + softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}) + if err != nil { + return nil, fmt.Errorf("applying software installers: %w", err) + } + + if dryRun { + logFn("[+] would've applied 'No Team' software packages\n") + } else { + logFn("[+] applied 'No Team' software packages\n") + } + } + return softwareInstallers, nil +} + +func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwarePackageResponse, logFn func(format string, args ...interface{}), dryRun bool) error { + var teamID *uint // Global policies (nil) + switch { + case config.TeamID != nil: // Team policies + teamID = config.TeamID + case config.IsNoTeam(): // "No team" policies + teamID = ptr.Uint(0) + } + // Get software titles of packages for the team. + if teamID != nil { + softwareTitleURLs := make(map[string]uint) + for _, softwareInstaller := range teamSoftwareInstallers { + if softwareInstaller.TitleID == nil { + // Should not happen, but to not panic we just log a warning. + logFn("[!] software installer without title id: team_id=%d, url=%s\n", *teamID, softwareInstaller.URL) + continue + } + if softwareInstaller.URL == "" { + // Should not happen because we previously applied packages via gitops, but to not panic we just log a warning. + logFn("[!] software installer without url: team_id=%d, title_id=%d\n", *teamID, *softwareInstaller.TitleID) + continue + } + softwareTitleURLs[softwareInstaller.URL] = *softwareInstaller.TitleID + } + for i := range config.Policies { + config.Policies[i].SoftwareTitleID = ptr.Uint(0) // 0 unsets the installer + + if config.Policies[i].InstallSoftware == nil { + continue + } + softwareTitleID, ok := softwareTitleURLs[config.Policies[i].InstallSoftwareURL] + if !ok { + // Should not happen because software packages are uploaded first. + if !dryRun { + logFn("[!] software URL without software title id: %s\n", config.Policies[i].InstallSoftwareURL) + } + continue + } + config.Policies[i].SoftwareTitleID = &softwareTitleID + } + } + // Get the ids and names of current policies to figure out which ones to delete - policies, err := c.GetPolicies(config.TeamID) + policies, err := c.GetPolicies(teamID) if err != nil { return fmt.Errorf("error getting current policies: %w", err) } + if len(config.Policies) > 0 { numPolicies := len(config.Policies) logFn("[+] syncing %d policies\n", numPolicies) @@ -1470,7 +1602,12 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, } totalApplied += end - i // Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps. - if err := c.ApplyPolicies(config.Policies[i:end]); err != nil { + policiesToApply := config.Policies[i:end] + policiesSpec := make([]*fleet.PolicySpec, len(policiesToApply)) + for i := range policiesToApply { + policiesSpec[i] = &policiesToApply[i].PolicySpec + } + if err := c.ApplyPolicies(policiesSpec); err != nil { return fmt.Errorf("error applying policies: %w", err) } logFn("[+] synced %d policies\n", totalApplied) @@ -1488,7 +1625,11 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, } if !found { policiesToDelete = append(policiesToDelete, oldItem.ID) - fmt.Printf("[-] deleting policy %s\n", oldItem.Name) + if !dryRun { + logFn("[-] deleting policy %s\n", oldItem.Name) + } else { + logFn("[-] would've deleted policy %s\n", oldItem.Name) + } } } if len(policiesToDelete) > 0 { @@ -1501,7 +1642,16 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, end = len(policiesToDelete) } totalDeleted += end - i - if err := c.DeletePolicies(config.TeamID, policiesToDelete[i:end]); err != nil { + var teamID *uint + switch { + case config.TeamID != nil: // Team policies + teamID = config.TeamID + case config.IsNoTeam(): // No team policies + teamID = ptr.Uint(fleet.PolicyNoTeamID) + default: // Global policies + teamID = nil + } + if err := c.DeletePolicies(teamID, policiesToDelete[i:end]); err != nil { return fmt.Errorf("error deleting policies: %w", err) } logFn("[-] deleted %d policies\n", totalDeleted) diff --git a/server/service/client_policies.go b/server/service/client_policies.go index a8425bebf5..089e6a2d47 100644 --- a/server/service/client_policies.go +++ b/server/service/client_policies.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" ) diff --git a/server/service/client_software.go b/server/service/client_software.go index 22c602e96c..60a0911093 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -1,6 +1,11 @@ package service import ( + "errors" + "fmt" + "net/url" + "time" + "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -25,3 +30,40 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu } return responseBody.SoftwareTitles, nil } + +func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) { + query, err := url.ParseQuery(opts.RawQuery()) + if err != nil { + return nil, err + } + return c.applySoftwareInstallers(softwareInstallers, query, opts.DryRun) +} + +func (c *Client) applySoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, query url.Values, dryRun bool) ([]fleet.SoftwarePackageResponse, error) { + path := "/api/latest/fleet/software/batch" + var resp batchSetSoftwareInstallersResponse + if err := c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, "POST", path, &resp, query.Encode()); err != nil { + return nil, err + } + if dryRun && resp.RequestUUID == "" { + return nil, nil + } + + requestUUID := resp.RequestUUID + for { + var resp batchSetSoftwareInstallersResultResponse + if err := c.authenticatedRequestWithQuery(nil, "GET", path+"/"+requestUUID, &resp, query.Encode()); err != nil { + return nil, err + } + switch { + case resp.Status == fleet.BatchSetSoftwareInstallersStatusProcessing: + time.Sleep(5 * time.Second) + case resp.Status == fleet.BatchSetSoftwareInstallersStatusFailed: + return nil, errors.New(resp.Message) + case resp.Status == fleet.BatchSetSoftwareInstallersStatusCompleted: + return resp.Packages, nil + default: + return nil, fmt.Errorf("unknown status: %q", resp.Status) + } + } +} diff --git a/server/service/client_teams.go b/server/service/client_teams.go index e2edc217a0..5d541e903c 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -93,14 +93,13 @@ func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, query.Encode()) } -func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) error { - verb, path := "POST", "/api/latest/fleet/software/batch" +func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) { query, err := url.ParseQuery(opts.RawQuery()) if err != nil { - return err + return nil, err } query.Add("team_name", tmName) - return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode()) + return c.applySoftwareInstallers(softwareInstallers, query, opts.DryRun) } func (c *Client) ApplyTeamAppStoreAppsAssociation(tmName string, vppBatchPayload []fleet.VPPBatchPayload, opts fleet.ApplySpecOptions) error { diff --git a/server/service/devices.go b/server/service/devices.go index 288fbb304e..9eb1ba3a3a 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -3,8 +3,9 @@ package service import ( "context" "crypto/x509" + "database/sql" "encoding/json" - "fmt" + "errors" "io" "net/http" "net/url" @@ -529,34 +530,37 @@ func (svc *Service) GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) ([]b return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("forbidden: only device-authenticated hosts can access this endpoint")) } - appConfig, err := svc.ds.AppConfig(ctx) + cfg, err := svc.ds.AppConfig(ctx) if err != nil { - return nil, ctxerr.Wrap(ctx, err) + return nil, ctxerr.Wrap(ctx, err, "fetching app config") } - topic, err := svc.mdmPushCertTopic(ctx) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") + host, ok := hostctx.FromContext(ctx) + if !ok { + return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) } - assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetSCEPChallenge, - }) - if err != nil { - return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) + tmSecrets, err := svc.ds.GetEnrollSecrets(ctx, host.TeamID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, err, "getting host team enroll secrets") + } + if len(tmSecrets) == 0 && host.TeamID != nil { + tmSecrets, err = svc.ds.GetEnrollSecrets(ctx, nil) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, err, "getting no team enroll secrets") + } + } + if len(tmSecrets) == 0 { + return nil, &fleet.BadRequestError{Message: "unable to find an enroll secret to generate enrollment profile"} } - enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig( - appConfig.OrgInfo.OrgName, - appConfig.ServerSettings.ServerURL, - string(assets[fleet.MDMAssetSCEPChallenge].Value), - topic, - ) + enrollSecret := tmSecrets[0].Secret + profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile") + return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file for manual enrollment") } - signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) + signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds) if err != nil { return nil, ctxerr.Wrap(ctx, err, "signing profile") } diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 13d4f00568..055eb2cba1 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -273,6 +273,12 @@ func makeDecoder(iface interface{}) kithttp.DecodeRequestFunc { return nil, badRequestErr("parsing uint from query", err) } field.SetUint(uint64(queryValUint)) + case reflect.Float64: + queryValFloat, err := strconv.ParseFloat(queryVal, 64) + if err != nil { + return nil, badRequestErr("parsing float from query", err) + } + field.SetFloat(queryValFloat) case reflect.Bool: field.SetBool(queryVal == "1" || queryVal == "true") case reflect.Int: @@ -442,9 +448,12 @@ var pathReplacer = strings.NewReplacer( "}", "_", ) -func getNameFromPathAndVerb(verb, path string) string { - return strings.ToLower(verb) + "_" + - pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/")) +func getNameFromPathAndVerb(verb, path, startAt string) string { + prefix := strings.ToLower(verb) + "_" + if startAt != "" { + prefix += pathReplacer.Replace(startAt) + "_" + } + return prefix + pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/")) } func capabilitiesResponseFunc(capabilities fleet.CapabilityMap) kithttp.ServerOption { @@ -554,14 +563,14 @@ func (e *authEndpointer) handlePathHandler(path string, pathHandler func(path st } versionedPath := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1) - nameAndVerb := getNameFromPathAndVerb(verb, path) + nameAndVerb := getNameFromPathAndVerb(verb, path, e.startingAtVersion) if e.usePathPrefix { e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) } else { e.r.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) } for _, alias := range e.alternativePaths { - nameAndVerb := getNameFromPathAndVerb(verb, alias) + nameAndVerb := getNameFromPathAndVerb(verb, alias, e.startingAtVersion) versionedPath := strings.Replace(alias, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1) if e.usePathPrefix { e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) diff --git a/server/service/frontend.go b/server/service/frontend.go index f5d884ec1f..a2a6058b54 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -1,9 +1,11 @@ package service import ( + "fmt" "html/template" "io" "net/http" + "net/url" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/fleetdm/fleet/v4/server/bindata" @@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl }) } +func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler { + herr := func(w http.ResponseWriter, err string) { + logger.Log("err", err) + http.Error(w, err, http.StatusInternalServerError) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeBrowserSecurityHeaders(w) + + fs := newBinaryFileSystem("/frontend") + file, err := fs.Open("templates/enroll-ota.html") + if err != nil { + herr(w, "load enroll ota template: "+err.Error()) + return + } + + data, err := io.ReadAll(file) + if err != nil { + herr(w, "read bindata file: "+err.Error()) + return + } + + t, err := template.New("enroll-ota").Parse(string(data)) + if err != nil { + herr(w, "create react template: "+err.Error()) + return + } + + enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret")) + if err != nil { + herr(w, "generate enroll ota url: "+err.Error()) + return + } + if err := t.Execute(w, struct { + EnrollURL string + URLPrefix string + }{ + URLPrefix: urlPrefix, + EnrollURL: enrollURL, + }); err != nil { + herr(w, "execute react template: "+err.Error()) + return + } + }) +} + +func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota") + if err != nil { + return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("parsing end user ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + return enrollURL.String(), nil +} + func ServeStaticAssets(path string) http.Handler { return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets"))) } diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go index 32363d6dd8..22df31872a 100644 --- a/server/service/frontend_test.go +++ b/server/service/frontend_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "io" "net/http" "net/http/httptest" "os" @@ -40,3 +41,28 @@ func TestServeFrontend(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) } + +func TestServeEndUserEnrollOTA(t *testing.T) { + if !hasBuildTag("full") { + t.Skip("This test requires running with -tags full") + } + logger := log.NewLogfmtLogger(os.Stdout) + h := ServeEndUserEnrollOTA("", logger) + ts := httptest.NewServer(h) + t.Cleanup(func() { + ts.Close() + }) + + // assert html is returned + response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo") + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8") + + // assert it contains the content we expect + defer response.Body.Close() + bodyBytes, err := io.ReadAll(response.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + require.Contains(t, bodyString, "api/v1/fleet/enrollment_profiles/ota?enroll_secret=foo") +} diff --git a/server/service/global_policies.go b/server/service/global_policies.go index c7d03e9695..87c1d67152 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -6,12 +6,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/fleetdm/fleet/v4/pkg/fleethttp" "io" "net/http" "strings" "time" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -155,6 +155,9 @@ func (svc Service) GetPolicyByIDQueries(ctx context.Context, policyID uint) (*fl if err != nil { return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } return policy, nil } @@ -484,7 +487,7 @@ func applyPolicySpecsEndpoint(ctx context.Context, request interface{}, svc flee func (svc *Service) checkPolicySpecAuthorization(ctx context.Context, policies []*fleet.PolicySpec) error { checkGlobalPolicyAuth := false for _, policy := range policies { - if policy.Team != "" { + if policy.Team != "" && policy.Team != "No team" { team, err := svc.ds.TeamByName(ctx, policy.Team) if err != nil { // This is so that the proper HTTP status code is returned @@ -583,8 +586,10 @@ func autofillPoliciesEndpoint(ctx context.Context, request interface{}, svc flee } // Exposing external URL and timeout for testing purposes -var getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" -var getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +var ( + getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" + getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +) type AutofillError struct { Message string diff --git a/server/service/handler.go b/server/service/handler.go index 3d606bd159..7012393952 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -367,14 +367,24 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/software/titles", listSoftwareTitlesEndpoint, listSoftwareTitlesRequest{}) ue.GET("/api/_version_/fleet/software/titles/{id:[0-9]+}", getSoftwareTitleEndpoint, getSoftwareTitleRequest{}) - ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/install/{software_title_id:[0-9]+}", installSoftwareTitleEndpoint, installSoftwareRequest{}) + ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/{software_title_id:[0-9]+}/install", installSoftwareTitleEndpoint, + installSoftwareRequest{}) + ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/{software_title_id:[0-9]+}/uninstall", uninstallSoftwareTitleEndpoint, + uninstallSoftwareRequest{}) - // Sofware installers + // Software installers ue.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/package", getSoftwareInstallerEndpoint, getSoftwareInstallerRequest{}) + ue.POST("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/package/token", getSoftwareInstallerTokenEndpoint, + getSoftwareInstallerRequest{}) ue.POST("/api/_version_/fleet/software/package", uploadSoftwareInstallerEndpoint, uploadSoftwareInstallerRequest{}) + ue.PATCH("/api/_version_/fleet/software/titles/{id:[0-9]+}/package", updateSoftwareInstallerEndpoint, updateSoftwareInstallerRequest{}) ue.DELETE("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/available_for_install", deleteSoftwareInstallerEndpoint, deleteSoftwareInstallerRequest{}) - ue.GET("/api/_version_/fleet/software/install/results/{install_uuid}", getSoftwareInstallResultsEndpoint, getSoftwareInstallResultsRequest{}) + ue.GET("/api/_version_/fleet/software/install/{install_uuid}/results", getSoftwareInstallResultsEndpoint, + getSoftwareInstallResultsRequest{}) + // POST /api/_version_/fleet/software/batch is asynchronous, meaning it will start the process of software download+upload in the background + // and will return a request UUID to be used in GET /api/_version_/fleet/software/batch/{request_uuid} to query for the status of the operation. ue.POST("/api/_version_/fleet/software/batch", batchSetSoftwareInstallersEndpoint, batchSetSoftwareInstallersRequest{}) + ue.GET("/api/_version_/fleet/software/batch/{request_uuid}", batchSetSoftwareInstallersResultEndpoint, batchSetSoftwareInstallersResultRequest{}) // App store software ue.GET("/api/_version_/fleet/software/app_store_apps", getAppStoreAppsEndpoint, getAppStoreAppsRequest{}) @@ -559,7 +569,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAppleMW.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{}) mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{}) mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{}) - mdmAppleMW.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{}) // Deprecated: GET /mdm/manual_enrollment_profile is now deprecated, replaced by the // GET /enrollment_profiles/manual endpoint. @@ -721,21 +730,30 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // new flow described in https://github.com/fleetdm/fleet/issues/10383 ue.POST("/api/_version_/fleet/mdm/apple/dep/key_pair", newMDMAppleDEPKeyPairEndpoint, nil) ue.GET("/api/_version_/fleet/mdm/apple/abm_public_key", generateABMKeyPairEndpoint, nil) - ue.POST("/api/_version_/fleet/mdm/apple/abm_token", uploadABMTokenEndpoint, uploadABMTokenRequest{}) - ue.DELETE("/api/_version_/fleet/mdm/apple/abm_token", disableABMEndpoint, nil) + ue.POST("/api/_version_/fleet/abm_tokens", uploadABMTokenEndpoint, uploadABMTokenRequest{}) + ue.DELETE("/api/_version_/fleet/abm_tokens/{id:[0-9]+}", deleteABMTokenEndpoint, deleteABMTokenRequest{}) + ue.GET("/api/_version_/fleet/abm_tokens", listABMTokensEndpoint, nil) + ue.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/teams", updateABMTokenTeamsEndpoint, updateABMTokenTeamsRequest{}) + ue.PATCH("/api/_version_/fleet/abm_tokens/{id:[0-9]+}/renew", renewABMTokenEndpoint, renewABMTokenRequest{}) ue.GET("/api/_version_/fleet/mdm/apple/request_csr", getMDMAppleCSREndpoint, getMDMAppleCSRRequest{}) ue.POST("/api/_version_/fleet/mdm/apple/apns_certificate", uploadMDMAppleAPNSCertEndpoint, uploadMDMAppleAPNSCertRequest{}) ue.DELETE("/api/_version_/fleet/mdm/apple/apns_certificate", deleteMDMAppleAPNSCertEndpoint, deleteMDMAppleAPNSCertRequest{}) - ue.POST("/api/_version_/fleet/mdm/apple/vpp_token", uploadMDMAppleVPPTokenEndpoint, uploadMDMAppleVPPTokenRequest{}) - ue.GET("/api/_version_/fleet/vpp", getMDMAppleVPPTokenEndpoint, getMDMAppleVPPTokenRequest{}) - ue.DELETE("/api/_version_/fleet/mdm/apple/vpp_token", deleteMDMAppleVPPTokenEndpoint, deleteMDMAppleVPPTokenRequest{}) + // VPP Tokens + ue.GET("/api/_version_/fleet/vpp_tokens", getVPPTokens, getVPPTokensRequest{}) + ue.POST("/api/_version_/fleet/vpp_tokens", uploadVPPTokenEndpoint, uploadVPPTokenRequest{}) + ue.PATCH("/api/_version_/fleet/vpp_tokens/{id}/teams", patchVPPTokensTeams, patchVPPTokensTeamsRequest{}) + ue.PATCH("/api/_version_/fleet/vpp_tokens/{id}/renew", patchVPPTokenRenewEndpoint, patchVPPTokenRenewRequest{}) + ue.DELETE("/api/_version_/fleet/vpp_tokens/{id}", deleteVPPToken, deleteVPPTokenRequest{}) + + // Batch VPP Associations ue.POST("/api/_version_/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsEndpoint, batchAssociateAppStoreAppsRequest{}) // Deprecated: GET /mdm/apple_bm is now deprecated, replaced by the // GET /abm endpoint. ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil) + // Deprecated: GET /abm is now deprecated, replaced by the GET /abm_tokens endpoint. ue.GET("/api/_version_/fleet/abm", getAppleBMEndpoint, nil) // Deprecated: POST /mdm/apple/profiles/batch is now deprecated, replaced by the @@ -860,6 +878,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC neAppleMDM.GET(apple_mdm.EnrollPath, mdmAppleEnrollEndpoint, mdmAppleEnrollRequest{}) neAppleMDM.GET(apple_mdm.InstallerPath, mdmAppleGetInstallerEndpoint, mdmAppleGetInstallerRequest{}) neAppleMDM.HEAD(apple_mdm.InstallerPath, mdmAppleHeadInstallerEndpoint, mdmAppleHeadInstallerRequest{}) + neAppleMDM.POST("/api/_version_/fleet/ota_enrollment", mdmAppleOTAEndpoint, mdmAppleOTARequest{}) // Deprecated: GET /mdm/bootstrap is now deprecated, replaced by the // GET /bootstrap endpoint. @@ -877,6 +896,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{}) + // Get OTA profile + neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) + // These endpoint are used by Microsoft devices during MDM device enrollment phase neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) @@ -907,6 +929,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.WithAltPaths("/api/v1/osquery/carve/block"). POST("/api/osquery/carve/block", carveBlockEndpoint, carveBlockRequest{}) + ne.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/package/token/{token}", downloadSoftwareInstallerEndpoint, + downloadSoftwareInstallerRequest{}) + ne.POST("/api/_version_/fleet/perform_required_password_reset", performRequiredPasswordResetEndpoint, performRequiredPasswordResetRequest{}) ne.POST("/api/_version_/fleet/users", createUserFromInviteEndpoint, createUserRequest{}) ne.GET("/api/_version_/fleet/invites/{token}", verifyInviteEndpoint, verifyInviteRequest{}) diff --git a/server/service/handler_test.go b/server/service/handler_test.go index df7ff9e04d..116b155ac9 100644 --- a/server/service/handler_test.go +++ b/server/service/handler_test.go @@ -76,7 +76,6 @@ func TestAPIRoutesConflicts(t *testing.T) { } func TestAPIRoutesMetrics(t *testing.T) { - t.Skip() ds := new(mock.Store) svc, _ := newTestService(t, ds, nil, nil) @@ -108,7 +107,8 @@ func TestAPIRoutesMetrics(t *testing.T) { routeNames := make(map[string]bool) err = router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { if _, ok := routeNames[route.GetName()]; ok { - t.Errorf("duplicate route name: %s", route.GetName()) + path, _ := route.GetPathTemplate() + t.Errorf("duplicate route name: %s (%s)", route.GetName(), path) } routeNames[route.GetName()] = true return nil @@ -194,7 +194,7 @@ func TestAPIRoutesMetrics(t *testing.T) { "go_memstats_alloc_bytes_total": 1, "go_memstats_buck_hash_sys_bytes": 1, "go_memstats_frees_total": 1, - "go_memstats_gc_cpu_fraction": 1, + "go_memstats_gc_cpu_fraction": 0, // does not appear to be reported anymore "go_memstats_gc_sys_bytes": 1, "go_memstats_heap_alloc_bytes": 1, "go_memstats_heap_idle_bytes": 1, diff --git a/server/service/hosts.go b/server/service/hosts.go index a60348f39d..a4db5b73dc 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -826,7 +826,7 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return err } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } @@ -962,7 +962,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, fi if err := svc.ds.AddHostsToTeam(ctx, teamID, hostIDs); err != nil { return err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs) @@ -1036,19 +1036,56 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { } if host != nil && (host.Platform == "ios" || host.Platform == "ipados") { - err := svc.verifyMDMConfiguredAndConnected(ctx, host) + // Get MDM commands already sent + commands, err := svc.ds.GetHostMDMCommands(ctx, host.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host MDM commands") + } + doAppRefetch := true + doDeviceInfoRefetch := true + for _, cmd := range commands { + switch cmd.CommandType { + case fleet.RefetchDeviceCommandUUIDPrefix: + doDeviceInfoRefetch = false + case fleet.RefetchAppsCommandUUIDPrefix: + doAppRefetch = false + } + } + if !doAppRefetch && !doDeviceInfoRefetch { + // Nothing to do. + return nil + } + err = svc.verifyMDMConfiguredAndConnected(ctx, host) if err != nil { return err } + hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2) cmdUUID := uuid.NewString() - err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "refetch apps with MDM") + if doAppRefetch { + err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "refetch apps with MDM") + } + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchAppsCommandUUIDPrefix, + }) } - // DeviceInformation is last because the refetch response clears the refetch_requested flag - err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchCommandUUIDPrefix+cmdUUID) + if doDeviceInfoRefetch { + // DeviceInformation is last because the refetch response clears the refetch_requested flag + err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "refetch host with MDM") + } + hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{ + HostID: host.ID, + CommandType: fleet.RefetchDeviceCommandUUIDPrefix, + }) + } + // Add commands to the database to track the commands sent + err = svc.ds.AddHostMDMCommands(ctx, hostMDMCommands) if err != nil { - return ctxerr.Wrap(ctx, err, "refetch host with MDM") + return ctxerr.Wrap(ctx, err, "add host mdm commands") } } diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index b2765516e2..26611cb829 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" "strconv" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/capabilities" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" @@ -23,9 +25,10 @@ import ( "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + kitlog "github.com/go-kit/log" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func TestHostDetails(t *testing.T) { @@ -607,8 +610,9 @@ func TestHostAuth(t *testing.T) { } return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -886,8 +890,9 @@ func TestAddHostsToTeamByFilter(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -928,8 +933,9 @@ func TestAddHostsToTeamByFilterLabel(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -960,8 +966,9 @@ func TestAddHostsToTeamByFilterEmptyHosts(t *testing.T) { ds.AddHostsToTeamFunc = func(ctx context.Context, teamID *uint, hostIDs []uint) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } emptyFilter := &map[string]interface{}{} @@ -1861,7 +1868,7 @@ func TestBulkOperationFilterValidation(t *testing.T) { func TestSetDiskEncryptionNotifications(t *testing.T) { ds := new(mock.Store) ctx := context.Background() - svc := &Service{ds: ds} + svc := &Service{ds: ds, logger: kitlog.NewNopLogger()} tests := []struct { name string @@ -1873,6 +1880,7 @@ func TestSetDiskEncryptionNotifications(t *testing.T) { getHostDiskEncryptionKey func(context.Context, uint) (*fleet.HostDiskEncryptionKey, error) expectedNotifications *fleet.OrbitConfigNotifications expectedError bool + disableCapability bool }{ { name: "no MDM configured", @@ -1943,6 +1951,24 @@ func TestSetDiskEncryptionNotifications(t *testing.T) { }, expectedError: false, }, + { + name: "darwin needs rotation but client is old", + host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, + appConfig: &fleet.AppConfig{ + MDM: fleet.MDM{EnabledAndConfigured: true}, + }, + diskEncryptionConfigured: true, + isConnectedToFleetMDM: true, + mdmInfo: nil, + getHostDiskEncryptionKey: func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) { + return &fleet.HostDiskEncryptionKey{Decryptable: ptr.Bool(false)}, nil + }, + expectedNotifications: &fleet.OrbitConfigNotifications{ + RotateDiskEncryptionKey: true, + }, + expectedError: false, + disableCapability: true, + }, { name: "darwin needs rotation", host: &fleet.Host{ID: 1, Platform: "darwin", OsqueryHostID: ptr.String("foo")}, @@ -2056,6 +2082,13 @@ func TestSetDiskEncryptionNotifications(t *testing.T) { return tt.appConfig, nil } + if !tt.disableCapability { + r := http.Request{ + Header: http.Header{fleet.CapabilitiesHeader: []string{string(fleet.CapabilityEscrowBuddy)}}, + } + ctx = capabilities.NewContext(ctx, &r) + } + notifs := &fleet.OrbitConfigNotifications{} err := svc.setDiskEncryptionNotifications(ctx, notifs, tt.host, tt.appConfig, tt.diskEncryptionConfigured, tt.isConnectedToFleetMDM, tt.mdmInfo) if tt.expectedError { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index e1a9a5150b..8b1f848205 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4048,21 +4048,28 @@ func (s *integrationTestSuite) TestLabels() { // modify manual label 1 without modifying its hosts modResp = modifyLabelResponse{} - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: ptr.String("modified_manual_label1")}, http.StatusOK, &modResp) + newName := "modified_manual_label1" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl1.ID), &fleet.ModifyLabelPayload{Name: &newName}, http.StatusOK, + &modResp) assert.Equal(t, manualLbl1.ID, modResp.Label.ID) assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) assert.ElementsMatch(t, []uint{manualHosts[0].ID, manualHosts[1].ID, manualHosts[2].ID}, modResp.Label.HostIDs) assert.EqualValues(t, 3, modResp.Label.HostCount) + assert.Equal(t, newName, modResp.Label.Name) // modify manual label 2 adding some hosts modResp = modifyLabelResponse{} - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), &fleet.ModifyLabelPayload{Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp) + newName = "modified_manual_label2" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/labels/%d", manualLbl2.ID), + &fleet.ModifyLabelPayload{Name: &newName, Hosts: []string{manualHosts[0].UUID}}, http.StatusOK, &modResp) assert.Equal(t, manualLbl2.ID, modResp.Label.ID) assert.Equal(t, fleet.LabelTypeRegular, modResp.Label.LabelType) assert.Equal(t, fleet.LabelMembershipTypeManual, modResp.Label.LabelMembershipType) assert.ElementsMatch(t, []uint{manualHosts[0].ID}, modResp.Label.HostIDs) assert.EqualValues(t, 1, modResp.Label.HostCount) + assert.Equal(t, newName, modResp.Label.Name) + manualLbl2.Name = newName // modify manual label 2 clearing its hosts modResp = modifyLabelResponse{} @@ -6162,6 +6169,55 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { "team_id", "1", ) + // a request with a premium vulnerability filter returns a license error + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{fleet.SoftwareTitleListOptions{VulnerableOnly: true, MinimumCVSS: 7.5}}, http.StatusPaymentRequired, &resp, + ) + verResp := listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, MinimumCVSS: 7.5}}, http.StatusPaymentRequired, &verResp, + ) + countResp := countSoftwareResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/count", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, MinimumCVSS: 7.5}}, http.StatusPaymentRequired, &countResp, + ) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{fleet.SoftwareTitleListOptions{VulnerableOnly: true, MaximumCVSS: 7.5}}, http.StatusPaymentRequired, &resp, + ) + verResp = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, MaximumCVSS: 7.5}}, http.StatusPaymentRequired, &verResp, + ) + countResp = countSoftwareResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/count", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, MaximumCVSS: 7.5}}, http.StatusPaymentRequired, &countResp, + ) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{fleet.SoftwareTitleListOptions{VulnerableOnly: true, KnownExploit: true}}, http.StatusPaymentRequired, &resp, + ) + verResp = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, KnownExploit: true}}, http.StatusPaymentRequired, &verResp, + ) + countResp = countSoftwareResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/count", + listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, KnownExploit: true}}, http.StatusPaymentRequired, &countResp, + ) + // 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) @@ -8605,7 +8661,7 @@ func (s *integrationTestSuite) TestListVulnerabilities() { _, err = s.ds.InsertOSVulnerability(context.Background(), fleet.OSVulnerability{ OSID: os.ID, - CVE: "CVE-2021-1234", + CVE: "CVE-2021-12345", ResolvedInVersion: *ptr.StringPtr("10.0.19043.2013"), }, fleet.MSRCSource) require.NoError(t, err) @@ -8662,15 +8718,16 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.NoError(t, err) // insert CVEMeta + knownCVE := "cve-2021-12999" mockTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ { - CVE: "CVE-2021-1234", + CVE: "CVE-2021-12345", CVSSScore: ptr.Float64(7.5), EPSSProbability: ptr.Float64(0.5), CISAKnownExploit: ptr.Bool(true), Published: ptr.Time(mockTime), - Description: "Test CVE 2021-1234", + Description: "Test CVE 2021-12345", }, { CVE: "CVE-2021-1235", @@ -8688,6 +8745,14 @@ func (s *integrationTestSuite) TestListVulnerabilities() { Published: ptr.Time(mockTime), Description: "Test CVE 2021-1246", }, + { + CVE: knownCVE, + CVSSScore: ptr.Float64(6.4), + EPSSProbability: ptr.Float64(0.61), + CISAKnownExploit: ptr.Bool(true), + Published: ptr.Time(mockTime), + Description: fmt.Sprintf("Test %s", knownCVE), + }, }) require.NoError(t, err) @@ -8708,9 +8773,9 @@ func (s *integrationTestSuite) TestListVulnerabilities() { DetailsLink string Source fleet.VulnerabilitySource }{ - "CVE-2021-1234": { + "CVE-2021-12345": { HostCount: 1, - DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", + DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-12345", }, "CVE-2021-1235": { HostCount: 1, @@ -8724,7 +8789,7 @@ func (s *integrationTestSuite) TestListVulnerabilities() { for _, vuln := range resp.Vulnerabilities { expectedVuln, ok := expected[vuln.CVE.CVE] - require.True(t, ok) + require.True(t, ok, vuln.CVE.CVE) require.Equal(t, expectedVuln.HostCount, vuln.HostsCount) require.Equal(t, expectedVuln.DetailsLink, vuln.DetailsLink) require.Empty(t, vuln.CVSSScore) @@ -8745,9 +8810,9 @@ func (s *integrationTestSuite) TestListVulnerabilities() { DetailsLink string Source fleet.VulnerabilitySource }{ - "CVE-2021-1234": { + "CVE-2021-12345": { HostCount: 1, - DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", + DetailsLink: "https://nvd.nist.gov/vuln/detail/CVE-2021-12345", }, "CVE-2021-1235": { HostCount: 1, @@ -8772,6 +8837,23 @@ func (s *integrationTestSuite) TestListVulnerabilities() { require.False(t, resp.Meta.HasPreviousResults) require.False(t, resp.Meta.HasNextResults) + // test with a known CVE that does not match on software/OS + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", knownCVE) + require.Empty(t, resp.Err) + assert.Len(s.T(), resp.Vulnerabilities, 0) + assert.Equal(t, resp.Count, uint(0)) + assert.False(t, resp.Meta.HasPreviousResults) + assert.False(t, resp.Meta.HasNextResults) + + // test with a substring of a known CVE -- results are returned + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "query", "CVE-2021-1234") + require.Empty(t, resp.Err) + assert.Len(s.T(), resp.Vulnerabilities, 1) + assert.Equal(t, resp.Count, uint(1)) + assert.False(t, resp.Meta.HasPreviousResults) + assert.False(t, resp.Meta.HasNextResults) + _ = s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusNotFound) + // Team 1 Filter s.DoJSON("GET", "/api/latest/fleet/vulnerabilities", nil, http.StatusOK, &resp, "team_id", "1") require.Len(s.T(), resp.Vulnerabilities, 0) @@ -8819,26 +8901,27 @@ func (s *integrationTestSuite) TestListVulnerabilities() { var gResp getVulnerabilityResponse // invalid cve - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/foobar", nil, http.StatusNotFound, &gResp) + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/foobar", nil, http.StatusBadRequest, &gResp) // Valid CVE but not in team scope - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusNotFound, &gResp, "team_id", fmt.Sprintf("%d", team.ID)) + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusNoContent, "team_id", + fmt.Sprintf("%d", team.ID)) // Valid CVE in "no team" scope s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1246", nil, http.StatusOK, &gResp, "team_id", "0") - // Valid CVD not in "no team" scope - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusNotFound, &gResp, "team_id", "0") + // Valid CVE not in "no team" scope + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusNoContent, "team_id", "0") // Invalid TeamID - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusForbidden, &gResp, "team_id", "100") + s.Do("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusForbidden, "team_id", "100") // Valid Global Request - s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-1234", nil, http.StatusOK, &gResp) + s.DoJSON("GET", "/api/latest/fleet/vulnerabilities/CVE-2021-12345", nil, http.StatusOK, &gResp) require.Empty(t, gResp.Err) - require.Equal(t, "CVE-2021-1234", gResp.Vulnerability.CVE.CVE) + require.Equal(t, "CVE-2021-12345", gResp.Vulnerability.CVE.CVE) require.Equal(t, uint(1), gResp.Vulnerability.HostsCount) - require.Equal(t, "https://nvd.nist.gov/vuln/detail/CVE-2021-1234", gResp.Vulnerability.DetailsLink) + require.Equal(t, "https://nvd.nist.gov/vuln/detail/CVE-2021-12345", gResp.Vulnerability.DetailsLink) require.Empty(t, gResp.Vulnerability.Description) require.Empty(t, gResp.Vulnerability.CVSSScore) require.Empty(t, gResp.Vulnerability.CISAKnownExploit) @@ -9146,7 +9229,11 @@ func (s *integrationTestSuite) TestOrbitConfigNotifications() { require.False(t, resp.Notifications.RenewEnrollmentProfile) // simulate ABM assignment - err = s.ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*hFleetMDM}) + encTok := uuid.NewString() + abmToken, err := s.ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + require.NoError(t, err) + require.NotEmpty(t, abmToken.ID) + err = s.ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*hFleetMDM}, abmToken.ID) require.NoError(t, err) err = s.ds.SetOrUpdateMDMData(context.Background(), hSimpleMDM.ID, false, true, "https://simplemdm.com", false, fleet.WellKnownMDMSimpleMDM, "") require.NoError(t, err) @@ -11508,6 +11595,9 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { t := s.T() ctx := context.Background() + adminUser, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + // there is already a datastore-layer test that verifies that correct values // are returned for users, saved scripts, etc. so this is more focused on // verifying that the service layer passes the proper options and the @@ -11552,6 +11642,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: adminUser.ID, }) require.NoError(t, err) s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/service/integration_ds_only_test.go b/server/service/integration_ds_only_test.go index 7275e5bc10..0d456a9c1e 100644 --- a/server/service/integration_ds_only_test.go +++ b/server/service/integration_ds_only_test.go @@ -15,9 +15,9 @@ type integrationDSTestSuite struct { suite.Suite } -func TestIntegrationDSTestSuite(t *testing.T) { +func TestIntegrationsDSTestSuite(t *testing.T) { testingSuite := new(integrationDSTestSuite) - testingSuite.s = &testingSuite.Suite + testingSuite.withDS.s = &testingSuite.Suite suite.Run(t, testingSuite) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 32420f39d6..cb2a979668 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -24,11 +24,14 @@ import ( "time" "github.com/fleetdm/fleet/v4/ee/server/calendar" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/cron" + "github.com/fleetdm/fleet/v4/server/datastore/filesystem" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" @@ -60,8 +63,9 @@ func TestIntegrationsEnterprise(t *testing.T) { type integrationEnterpriseTestSuite struct { withServer suite.Suite - redisPool fleet.RedisPool - calendarSchedule *schedule.Schedule + redisPool fleet.RedisPool + calendarSchedule *schedule.Schedule + softwareInstallStore fleet.SoftwareInstallerStore lq *live_query_mock.MockLiveQuery } @@ -72,6 +76,13 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { s.redisPool = redistest.SetupRedis(s.T(), "integration_enterprise", false, false, false) s.lq = live_query_mock.New(s.T()) var calendarSchedule *schedule.Schedule + + // Create a software install store + dir := s.T().TempDir() + softwareInstallStore, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(s.T(), err) + s.softwareInstallStore = softwareInstallStore + config := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, @@ -98,6 +109,7 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { } }, }, + SoftwareInstallStore: softwareInstallStore, } if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { config.Logger = kitlog.NewNopLogger() @@ -938,6 +950,141 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicies() { require.Len(t, ts.Policies, 0) } +func (s *integrationEnterpriseTestSuite) TestNoTeamPolicies() { + t := s.T() + ctx := context.Background() + + // + // Test a global admin can read and write "No team" policies. + // + + // List "No team" policies. + ts := listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 0) + require.Len(t, ts.InheritedPolicies, 0) + // Create a placeholder global policy. + _, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "globalPolicy1", + Query: "SELECT 0;", + }) + require.NoError(t, err) + // Create a "No team" policy. + tpParams := teamPolicyRequest{ + Name: "noTeamPolicy1", + Query: "SELECT 1;", + } + r := teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r) + require.NotNil(t, r.Policy.TeamID) + require.Zero(t, *r.Policy.TeamID) + // Test that we can't create a policy with the same name under "No team" domain. + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusConflict, &r) + // Create a second "No team" policy. + tpParams = teamPolicyRequest{ + Name: "noTeamPolicy2", + Query: "SELECT 2;", + } + r = teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &r) + require.NotNil(t, r.Policy.TeamID) + require.Zero(t, *r.Policy.TeamID) + // List "No team" policies. + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 2) + assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name) + assert.Equal(t, "SELECT 1;", ts.Policies[0].Query) + require.NotNil(t, ts.Policies[0].TeamID) + require.Zero(t, *ts.Policies[0].TeamID) + assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[1].Query) + require.NotNil(t, ts.Policies[1].TeamID) + require.Zero(t, *ts.Policies[1].TeamID) + require.Len(t, ts.InheritedPolicies, 1) + assert.Equal(t, "globalPolicy1", ts.InheritedPolicies[0].Name) + assert.Equal(t, "SELECT 0;", ts.InheritedPolicies[0].Query) + assert.Nil(t, ts.InheritedPolicies[0].TeamID) + // Test policy count for "No team" policies. + tc := countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &tc) + require.Equal(t, 2, tc.Count) + // Test merge inherited for "No team" policies. + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts, "merge_inherited", "true", "order_key", "team_id", "order_direction", "desc") + require.Len(t, ts.Policies, 3) + require.Nil(t, ts.InheritedPolicies) + assert.Equal(t, "noTeamPolicy1", ts.Policies[0].Name) + assert.Equal(t, "SELECT 1;", ts.Policies[0].Query) + assert.Equal(t, "noTeamPolicy2", ts.Policies[1].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[1].Query) + assert.Equal(t, "globalPolicy1", ts.Policies[2].Name) + assert.Equal(t, "SELECT 0;", ts.Policies[2].Query) + // Test merge inherited count for "No team" policies. + countResp := countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusOK, &countResp, "merge_inherited", "true") + require.Nil(t, countResp.Err) + require.Equal(t, 3, countResp.Count) + // Test deleting "No team" policies. + deletePolicyParams := deleteTeamPoliciesRequest{ + IDs: []uint{ts.Policies[0].ID}, + } + deletePolicyResp := deleteTeamPoliciesResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusOK, &deletePolicyResp) + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusOK, &ts) + require.Len(t, ts.Policies, 1) + assert.Equal(t, "noTeamPolicy2", ts.Policies[0].Name) + assert.Equal(t, "SELECT 2;", ts.Policies[0].Query) + noTeamPolicy2 := ts.Policies[0] + + // + // Test that a team admin is not allowed to access "No team" policies. + // + + team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + oldToken := s.token + t.Cleanup(func() { + s.token = oldToken + }) + password := test.GoodPassword + email := "testteam@user.com" + team1Admin := &fleet.User{ + Name: "test team user", + Email: email, + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team1, + Role: fleet.RoleAdmin, + }, + }, + } + require.NoError(t, team1Admin.SetPassword(password, 10, 10)) + _, err = s.ds.NewUser(context.Background(), team1Admin) + require.NoError(t, err) + + s.token = s.getTestToken(email, password) + + ts = listTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies", nil, http.StatusForbidden, &ts) + tpParams = teamPolicyRequest{ + Name: "noTeamPolicy1", + Query: "SELECT 1;", + } + r = teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusForbidden, &r) + tc = countTeamPoliciesResponse{} + s.DoJSON("GET", "/api/latest/fleet/teams/0/policies/count", nil, http.StatusForbidden, &tc) + deletePolicyParams = deleteTeamPoliciesRequest{ + IDs: []uint{noTeamPolicy2.ID}, + } + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies/delete", deletePolicyParams, http.StatusForbidden, &deleteTeamPoliciesResponse{}) +} + func (s *integrationEnterpriseTestSuite) TestTeamQueries() { t := s.T() @@ -1120,6 +1267,20 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { tmResp.Team = nil s.DoJSON("POST", "/api/latest/fleet/teams", team2, http.StatusConflict, &tmResp) + // create a team with reserved team names; should be case-insensitive + teamReserved := &fleet.Team{ + Name: "no TeAm", + Description: "description", + Secrets: []*fleet.EnrollSecret{{Secret: "foobar"}}, + } + + r := s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`) + + teamReserved.Name = "AlL TeaMS" + r = s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`) + // create a team with too many secrets team3 := &fleet.Team{ Name: name + "lots_of_secrets", @@ -1219,6 +1380,13 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { modifyExpiry.HostExpirySettings.HostExpiryWindow = 0 s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyExpiry, http.StatusUnprocessableEntity, &tmResp) + // try to rename to reserved names + r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("no TEAM")}, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`) + + r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("ALL teAMs")}, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`) + // Modify team's calendar config modifyCalendar := fleet.TeamPayload{ Integrations: &fleet.TeamIntegrations{ @@ -2890,44 +3058,6 @@ func (s *integrationEnterpriseTestSuite) TestCustomTransparencyURL() { require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location")) } -func (s *integrationEnterpriseTestSuite) TestDefaultAppleBMTeam() { - t := s.T() - - tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ - Name: t.Name(), - Description: "desc", - }) - require.NoError(s.T(), err) - - var acResp appConfigResponse - - // try to set an invalid team name - s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { - "apple_bm_default_team": "xyz" - } - }`), http.StatusUnprocessableEntity, &acResp) - - // get the appconfig, nothing changed - acResp = appConfigResponse{} - s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - require.Empty(t, acResp.MDM.AppleBMDefaultTeam) - - // set to a valid team name - acResp = appConfigResponse{} - s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ - "mdm": { - "apple_bm_default_team": %q - } - }`, tm.Name)), http.StatusOK, &acResp) - require.Equal(t, tm.Name, acResp.MDM.AppleBMDefaultTeam) - - // get the appconfig, set to that team name - acResp = appConfigResponse{} - s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - require.Equal(t, tm.Name, acResp.MDM.AppleBMDefaultTeam) -} - func (s *integrationEnterpriseTestSuite) TestMDMWindowsUpdates() { t := s.T() @@ -4704,6 +4834,13 @@ func createHostAndDeviceToken(t *testing.T, ds *mysql.Datastore, token string) * return host } +func updateDeviceTokenForHost(t *testing.T, ds *mysql.Datastore, hostID uint, token string) { + mysql.ExecAdhocSQL(t, ds, func(db sqlx.ExtContext) error { + _, err := db.ExecContext(context.Background(), `UPDATE host_device_auth SET token = ? WHERE host_id = ?`, token, hostID) + return err + }) +} + func createDeviceTokenForHost(t *testing.T, ds *mysql.Datastore, hostID uint, token string) { mysql.ExecAdhocSQL(t, ds, func(db sqlx.ExtContext) error { _, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, hostID, token) @@ -4815,6 +4952,108 @@ func (s *integrationEnterpriseTestSuite) TestListSoftware() { require.Equal(t, barPayload.Vulnerabilities[0].CVEPublished, ptr.TimePtr(now)) require.Equal(t, barPayload.Vulnerabilities[0].Description, ptr.StringPtr("a long description of the cve")) require.Equal(t, barPayload.Vulnerabilities[0].ResolvedInVersion, ptr.StringPtr("1.2.3")) + + // vulnerable param required when using vulnerability filters + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusUnprocessableEntity, &respVersions, + "exploit", "true", + ) + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusUnprocessableEntity, &respVersions, + "min_cvss_score", "1.1", + ) + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusUnprocessableEntity, &respVersions, + "max_cvss_score", "10.0", + ) + + // vulnerability filters + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "exploit", "true", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 1) + require.NotEmpty(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "min_cvss_score", "1", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 1) + require.NotEmpty(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "min_cvss_score", "10", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 0) + require.Nil(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "max_cvss_score", "10", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 1) + require.NotEmpty(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "max_cvss_score", "1", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 0) + require.Nil(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "min_cvss_score", "1", + "max_cvss_score", "10", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 1) + require.NotEmpty(t, respVersions.CountsUpdatedAt) + + respVersions = listSoftwareVersionsResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/versions", + listSoftwareRequest{}, + http.StatusOK, &respVersions, + "min_cvss_score", "1", + "max_cvss_score", "10", + "exploit", "true", + "vulnerable", "true", + ) + require.Len(t, respVersions.Software, 1) + require.NotEmpty(t, respVersions.CountsUpdatedAt) } // TestGitOpsUserActions tests the MDM permissions listed in ../../docs/Using\ Fleet/manage-access.md @@ -5814,7 +6053,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { case r := <-resultsCh: r.ExecutionID = pending[0].ExecutionID // ignoring errors in this goroutine, the HTTP request below will fail if this fails - _, err = s.ds.SetHostScriptExecutionResult(ctx, r) + _, _, err = s.ds.SetHostScriptExecutionResult(ctx, r) if err != nil { t.Log(err) } @@ -7684,6 +7923,15 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NoError(t, err) require.True(t, inserted) + err = s.ds.InsertCVEMeta(context.Background(), []fleet.CVEMeta{ + { + CVE: "cve-123-123-132", + CVSSScore: ptr.Float64(7.8), + CISAKnownExploit: ptr.Bool(true), + }, + }) + require.NoError(t, err) + // calculate hosts counts hostsCountTs := time.Now().UTC() require.NoError(t, s.ds.SyncHostsSoftware(ctx, hostsCountTs)) @@ -7786,16 +8034,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles) // asking for vulnerable only software returns the expected values - resp = listSoftwareTitlesResponse{} - s.DoJSON( - "GET", "/api/latest/fleet/software/titles", - listSoftwareTitlesRequest{}, - http.StatusOK, &resp, - "vulnerable", "true", - ) - require.Equal(t, 1, resp.Count) - require.NotEmpty(t, resp.CountsUpdatedAt) - softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{ + expectedVulnSoftware := []fleet.SoftwareTitleListResult{ { Name: "bar", Source: "apps", @@ -7805,7 +8044,127 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { {Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}}, }, }, - }, resp.SoftwareTitles) + } + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) + + // vulnerable param required when using vulnerability filters + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusUnprocessableEntity, &resp, + "exploit", "true", + ) + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusUnprocessableEntity, &resp, + "min_cvss_score", "1", + ) + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusUnprocessableEntity, &resp, + "max_cvss_score", "10", + ) + + // vulnerability filters + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "exploit", "true", + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "min_cvss_score", "1", + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "min_cvss_score", "10", + "vulnerable", "true", + ) + require.Zero(t, resp.Count) + require.Nil(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "max_cvss_score", "10", + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "max_cvss_score", "1", + "vulnerable", "true", + ) + require.Zero(t, resp.Count) + require.Nil(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch([]fleet.SoftwareTitleListResult{}, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "min_cvss_score", "1", + "max_cvss_score", "10", + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "min_cvss_score", "1", + "max_cvss_score", "10", + "exploit", "true", + "vulnerable", "true", + ) + require.Equal(t, 1, resp.Count) + require.NotEmpty(t, resp.CountsUpdatedAt) + softwareTitleListResultsMatch(expectedVulnSoftware, resp.SoftwareTitles) // request titles for team1, nothing there yet resp = listSoftwareTitlesResponse{} @@ -8299,7 +8658,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService) require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService) - // no team but self-service returns the emacs software (technically impossible via the UI) + // "All teams" returns no software because the self-service software it's not installed (host_counts == 0). resp = listSoftwareTitlesResponse{} s.DoJSON( "GET", "/api/latest/fleet/software/titles", @@ -8308,15 +8667,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { "self_service", "true", ) - require.Len(t, resp.SoftwareTitles, 2) - require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) - require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage.SelfService) - require.True(t, *resp.SoftwareTitles[0].SoftwarePackage.SelfService) - require.NotNil(t, resp.SoftwareTitles[1].SoftwarePackage) - require.NotNil(t, resp.SoftwareTitles[1].SoftwarePackage.SelfService) - require.True(t, *resp.SoftwareTitles[1].SoftwarePackage.SelfService) + require.Empty(t, resp.SoftwareTitles, 0) - // team 0 returns the emacs software + // "No team" returns the emacs software resp = listSoftwareTitlesResponse{} s.DoJSON( "GET", "/api/latest/fleet/software/titles", @@ -9754,7 +10107,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { // request installation on the host var installResp installSoftwareResponse - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &installResp) // still returned by user-authenticated endpoint, now pending @@ -9768,7 +10121,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.NotNil(t, getHostSw.Software[2].SoftwarePackage) require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name) require.NotNil(t, getHostSw.Software[2].Status) - require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status) + require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[2].Status) require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService) require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService) @@ -9789,7 +10142,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[2].Name, "ruby") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) require.NotNil(t, getDeviceSw.Software[2].Status) - require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status) + require.Equal(t, fleet.SoftwareInstallPending, *getDeviceSw.Software[2].Status) require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage) require.Nil(t, getDeviceSw.Software[2].AppStoreApp) require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage.SelfService) @@ -9821,6 +10174,36 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Len(t, getDeviceSw.Software, 1) // bar only require.Equal(t, getDeviceSw.Software[0].Name, "bar") require.Len(t, getDeviceSw.Software[0].InstalledVersions, 1) + + // Add new software to host -- installed on host, but not by Fleet + installedVersion := "1.0.1" + softwareAlreadyInstalled := fleet.Software{ + Name: "DummyApp.app", Version: installedVersion, Source: "apps", + BundleIdentifier: "com.example.dummy", + } + software = append(software, softwareAlreadyInstalled) + _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) + require.NoError(t, err) + err = s.ds.ReconcileSoftwareTitles(ctx) + require.NoError(t, err) + // Add installer for software that is already installed on host + payload = &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "dummy_installer.pkg", + Version: "0.0.2", // The version can be anything -- we match on title + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // Get software available for install + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "available_for_install", + "true", "order_key", "name", "order_direction", "asc") + require.Len(t, getHostSw.Software, 2) // DummyApp.app and ruby + assert.Equal(t, softwareAlreadyInstalled.Name, getHostSw.Software[0].Name) + require.Len(t, getHostSw.Software[0].InstalledVersions, 1) + assert.Equal(t, installedVersion, getHostSw.Software[0].InstalledVersions[0].Version) + assert.NotNil(t, getHostSw.Software[0].SoftwarePackage) + assert.Nil(t, getHostSw.Software[0].Status) } func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() { @@ -9892,7 +10275,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD meta, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), id) require.NoError(t, err) - if payload.TeamID != nil { + if payload.TeamID != nil && *payload.TeamID > 0 { require.Equal(t, *payload.TeamID, *meta.TeamID) } else { require.Nil(t, meta.TeamID) @@ -9951,8 +10334,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusBadRequest) - // delete the installer + // delete the installer from nil team fails s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusBadRequest) + + // delete from team 0 succeeds + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") }) t.Run("create team software installer", func(t *testing.T) { @@ -9991,18 +10377,44 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) checkDownloadResponse(t, r, payload.Filename) + // download the installer by getting token first + tokenResp := getSoftwareInstallerTokenResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=media", titleID), nil, http.StatusOK, + &tokenResp, "team_id", fmt.Sprintf("%d", *payload.TeamID)) + require.NotEmpty(t, tokenResp.Token) + r = s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token/%s", titleID, tokenResp.Token), nil, + http.StatusOK) + checkDownloadResponse(t, r, payload.Filename) + + // downloading a second time using the same token should fail + _ = s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token/%s", titleID, tokenResp.Token), nil, + http.StatusForbidden) + + // alt != media should fail + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=bozo", titleID), nil, + http.StatusUnprocessableEntity, + &tokenResp, "team_id", fmt.Sprintf("%d", *payload.TeamID)) + + // missing team_id should fail + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package/token?alt=media", titleID), nil, + http.StatusBadRequest, + &tokenResp) + // create an orbit host that is not in the team hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds) - // downloading installer still works because we allow it explicitly + // downloading installer doesn't work if the host doesn't have a pending install request s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, OrbitNodeKey: *hostNotInTeam.OrbitNodeKey, - }, http.StatusOK) + }, http.StatusForbidden) // create an orbit host, assign to team - hostInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) + hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds) require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{hostInTeam.ID})) + // Create a software installation request + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + // requesting download with alt != media fails r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ InstallerID: installerID, @@ -10018,6 +10430,28 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD }, http.StatusOK) checkDownloadResponse(t, r, payload.Filename) + // Get execution ID, normally comes from orbit config + var installUUID string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &installUUID, "SELECT execution_id FROM host_software_installs WHERE host_id = ? AND install_script_exit_code IS NULL", hostInTeam.ID) + }) + + // Installation complete, host no longer has access to software + s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{ + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{ + HostID: hostInTeam.ID, + InstallUUID: installUUID, + InstallScriptExitCode: ptr.Int(0), + InstallScriptOutput: ptr.String("done"), + }, + }, http.StatusNoContent) + + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusForbidden) + // delete the installer s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) @@ -10027,6 +10461,200 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer, not found anymore s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", *payload.TeamID)) }) + + t.Run("create team 0 software installer", func(t *testing.T) { + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: ptr.Uint(0), + InstallScript: "another install script", + PreInstallQuery: "another pre install query", + PostInstallScript: "another post install script", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + SelfService: true, + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0) + + // upload again fails + s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + + // download the installer + r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0)) + checkDownloadResponse(t, r, payload.Filename) + + // create an orbit host that is not in the team + hostNotInTeam := createOrbitEnrolledHost(t, "windows", "orbit-host-no-team", s.ds) + // downloading installer fails because there's no install request + s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostNotInTeam.OrbitNodeKey, + }, http.StatusForbidden) + + // create an orbit host, assign to team + hostInTeam := createOrbitEnrolledHost(t, "linux", "orbit-host-team", s.ds) + + // requesting download with alt != media fails + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=FOOBAR", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusBadRequest) + errMsg := extractServerErrorText(r.Body) + require.Contains(t, errMsg, "only alt=media is supported") + + // Create a software installation request + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostInTeam.ID, titleID), installSoftwareRequest{}, http.StatusAccepted) + + // valid download + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusOK) + checkDownloadResponse(t, r, payload.Filename) + + // Get execution ID, normally comes from orbit config + var installUUID string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &installUUID, "SELECT execution_id FROM host_software_installs WHERE host_id = ? AND install_script_exit_code IS NULL", hostInTeam.ID) + }) + + // Installation complete, host no longer has access to software + s.Do("POST", "/api/fleet/orbit/software_install/result", orbitPostSoftwareInstallResultRequest{ + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + HostSoftwareInstallResultPayload: &fleet.HostSoftwareInstallResultPayload{ + HostID: hostInTeam.ID, + InstallUUID: installUUID, + InstallScriptExitCode: ptr.Int(0), + InstallScriptOutput: ptr.String("done"), + }, + }, http.StatusNoContent) + + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *hostInTeam.OrbitNodeKey, + }, http.StatusForbidden) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`), 0) + + // download the installer, not found anymore + s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0)) + }) + + t.Run("uninstall migration for software installer", func(t *testing.T) { + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &createTeamResp.Team.ID, + InstallScript: "another install script", + UninstallScript: "exit 1", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + logger := kitlog.NewLogfmtLogger(os.Stderr) + + // Run the migration when nothing is to be done + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + var origPackageIDs string + var origExtension string + // Update DB by clearing package id and tweaking extension + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + if err := sqlx.GetContext(context.Background(), q, &origPackageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + require.NotEmpty(t, origPackageIDs) + + if err := sqlx.GetContext(context.Background(), q, &origExtension, `SELECT extension FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + require.NotEmpty(t, origExtension) + + if _, err = q.ExecContext(context.Background(), `UPDATE software_installers SET package_ids = '', extension = 'rb' WHERE id = ?`, + installerID); err != nil { + return err + } + return nil + }) + + // Check title to make it works without package id + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, "exit 1", respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Run the migration + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // Check package ID and extension + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var packageIDs string + if err := sqlx.GetContext(context.Background(), q, &packageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + assert.Equal(t, origPackageIDs, packageIDs) + + var extension string + if err := sqlx.GetContext(context.Background(), q, &extension, `SELECT extension FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + assert.Equal(t, origExtension, extension) + + return nil + }) + + // Check uninstall script + uninstallScript := file.GetUninstallScript("deb") + uninstallScript = strings.ReplaceAll(uninstallScript, "$PACKAGE_ID", "\"ruby\"") + respTitle = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, uninstallScript, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Running the migration again causes no issues. + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, + "team_id", fmt.Sprintf("%d", *payload.TeamID)) + }) } func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { @@ -10092,7 +10720,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { } s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) - wantSoftwarePackages := []fleet.TeamSpecSoftwarePackage{ + wantSoftwarePackages := []fleet.SoftwarePackageSpec{ { URL: "http://foo.com", SelfService: true, @@ -10251,9 +10879,6 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { t := s.T() - // a team name is required (we don't allow installers for "no team") - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusBadRequest) - // non-existent team s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo") @@ -10270,8 +10895,18 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { } s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name) + // software with a too big URL + softwareToInstall = []fleet.SoftwareInstallerPayload{ + {URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 233)}, + } + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name) + // create an HTTP server to host the software installer handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ruby.deb" { + w.WriteHeader(http.StatusNotFound) + return + } file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb")) require.NoError(t, err) defer file.Close() @@ -10283,11 +10918,28 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { srv := httptest.NewServer(handler) t.Cleanup(srv.Close) - // do a request with a valid URL + // do a request with a URL that returns a 404. softwareToInstall = []fleet.SoftwareInstallerPayload{ - {URL: srv.URL}, + {URL: srv.URL + "/not_found.pkg"}, } - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + var batchResponse batchSetSoftwareInstallersResponse + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + message := waitBatchSetSoftwareInstallersFailed(t, s, tm.Name, batchResponse.RequestUUID) + require.NotEmpty(t, message) + require.Contains(t, message, fmt.Sprintf("validation failed: software.url Couldn't edit software. URL (\"%s/not_found.pkg\") returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", srv.URL)) + + // do a request with a valid URL + rubyURL := srv.URL + "/ruby.deb" + softwareToInstall = []fleet.SoftwareInstallerPayload{ + {URL: rubyURL}, + } + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages := waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) // TODO(roberto): test with a variety of response codes @@ -10296,6 +10948,9 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, 1, titlesResp.Count) require.Len(t, titlesResp.SoftwareTitles, 1) + // Check that the URL is set to software installers uploaded via batch. + require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.Equal(t, rubyURL, *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) // check that platform is set when the installer is created mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -10308,14 +10963,26 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { }) // same payload doesn't modify anything - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) newTitlesResp := listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, titlesResp, newTitlesResp) // setting self-service to true updates the software title metadata softwareToInstall[0].SelfService = true - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) @@ -10323,11 +10990,420 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { // empty payload cleans the software items softwareToInstall = []fleet.SoftwareInstallerPayload{} - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Empty(t, packages) titlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, 0, titlesResp.Count) require.Len(t, titlesResp.SoftwareTitles, 0) + + ////////////////////////// + // Do a request with a valid URL with no team + ////////////////////////// + softwareToInstall = []fleet.SoftwareInstallerPayload{ + {URL: rubyURL}, + } + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.Nil(t, packages[0].TeamID) + + // check the application status on team 0 + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, 1, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 1) + + // same payload doesn't modify anything + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.Nil(t, packages[0].TeamID) + newTitlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, titlesResp, newTitlesResp) + + // setting self-service to true updates the software title metadata + softwareToInstall[0].SelfService = true + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.Equal(t, rubyURL, packages[0].URL) + require.Nil(t, packages[0].TeamID) + newTitlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) + require.Equal(t, titlesResp, newTitlesResp) + + // empty payload cleans the software items + softwareToInstall = []fleet.SoftwareInstallerPayload{} + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID) + require.Empty(t, packages) + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) + require.Equal(t, 0, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 0) +} + +func waitBatchSetSoftwareInstallersCompleted(t *testing.T, s *integrationEnterpriseTestSuite, teamName string, requestUUID string) []fleet.SoftwarePackageResponse { + timeout := time.After(1 * time.Minute) + for { + var batchResultResponse batchSetSoftwareInstallersResultResponse + s.DoJSON("GET", "/api/latest/fleet/software/batch/"+requestUUID, nil, http.StatusOK, &batchResultResponse, "team_name", teamName) + if batchResultResponse.Status == fleet.BatchSetSoftwareInstallersStatusCompleted { + return batchResultResponse.Packages + } + select { + case <-timeout: + t.Fatalf("timeout: %s, %s", teamName, requestUUID) + case <-time.After(500 * time.Millisecond): + // OK, continue + } + } +} + +func waitBatchSetSoftwareInstallersFailed(t *testing.T, s *integrationEnterpriseTestSuite, teamName string, requestUUID string) string { + timeout := time.After(1 * time.Minute) + for { + var batchResultResponse batchSetSoftwareInstallersResultResponse + s.DoJSON("GET", "/api/latest/fleet/software/batch/"+requestUUID, nil, http.StatusOK, &batchResultResponse, "team_name", teamName) + if batchResultResponse.Status == fleet.BatchSetSoftwareInstallersStatusFailed { + require.Empty(t, batchResultResponse.Packages) + return batchResultResponse.Message + } + select { + case <-timeout: + t.Fatalf("timeout: %s, %s", teamName, requestUUID) + case <-time.After(500 * time.Millisecond): + // OK, continue + } + } +} + +func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffects() { + t := s.T() + + // create a team + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(t, err) + + // create an HTTP server to host the software installer + trailer := "" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb")) + require.NoError(t, err) + defer file.Close() + w.Header().Set("Content-Type", "application/vnd.debian.binary-package") + _, err = io.Copy(w, file) + require.NoError(t, err) + _, err = w.Write([]byte(trailer)) + require.NoError(t, err) + }) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + // set up software to install + softwareToInstall := []fleet.SoftwareInstallerPayload{ + {URL: srv.URL}, + } + var batchResponse batchSetSoftwareInstallersResponse + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages := waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) + require.Equal(t, srv.URL, packages[0].URL) + titlesResp := listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) + titleResponse := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", titlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse, "team_id", strconv.Itoa(int(tm.ID))) + uploadedAt := titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt + + // create a host that doesn't have fleetd installed + h, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), + NodeKey: ptr.String(t.Name() + uuid.New().String()), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "linux", + }) + require.NoError(t, err) + err = s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{h.ID}) + require.NoError(t, err) + h.TeamID = &tm.ID + + // host installs fleetd + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey + + // install software + installResp := installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &installResp) + + // Get the install response, should be pending + getHostSoftwareResp := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status) + + // Switch self-service flag + softwareToInstall[0].SelfService = true + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) + require.Equal(t, srv.URL, packages[0].URL) + newTitlesResp := listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) + require.Equal(t, true, *newTitlesResp.SoftwareTitles[0].SoftwarePackage.SelfService) + + // Install should still be pending + afterSelfServiceHostResp := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterSelfServiceHostResp) + require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status) + + // update pre-install query + withUpdatedPreinstallQuery := []fleet.SoftwareInstallerPayload{ + {URL: srv.URL, PreInstallQuery: "SELECT * FROM os_version"}, + } + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedPreinstallQuery}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) + require.Equal(t, srv.URL, packages[0].URL) + titleResponse = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse, "team_id", strconv.Itoa(int(tm.ID))) + require.Equal(t, "SELECT * FROM os_version", titleResponse.SoftwareTitle.SoftwarePackage.PreInstallQuery) + require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall) + + // install should no longer be pending + afterPreinstallHostResp := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &afterPreinstallHostResp) + require.Nil(t, afterPreinstallHostResp.Software[0].Status) + + // install software fully + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titlesResp.SoftwareTitles[0].ID), nil, http.StatusAccepted, &installResp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent) + + // ensure install count is updated + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse, "team_id", strconv.Itoa(int(tm.ID))) + require.Equal(t, uint(1), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed) + require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall) + + // install should show as complete + hostResp := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp) + require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status) + + // update install script + withUpdatedInstallScript := []fleet.SoftwareInstallerPayload{ + {URL: srv.URL, InstallScript: "apt install ruby"}, + } + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) + require.Equal(t, srv.URL, packages[0].URL) + + // ensure install count is the same, and uploaded_at hasn't changed + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse, "team_id", strconv.Itoa(int(tm.ID))) + require.Equal(t, uint(1), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed) + require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall) + require.Equal(t, uploadedAt, titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt) + + // install should still show as complete + hostResp = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp) + require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status) + + trailer = " " // add a character to the response for the installer HTTP call to ensure the file hashes differently + // update package + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusOK, &batchResponse, "team_name", tm.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, tm.ID, *packages[0].TeamID) + require.Equal(t, srv.URL, packages[0].URL) + + // ensure install count is zeroed and uploaded_at HAS changed + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", newTitlesResp.SoftwareTitles[0].ID), nil, http.StatusOK, &titleResponse, "team_id", strconv.Itoa(int(tm.ID))) + require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.Installed) + require.Equal(t, uint(0), titleResponse.SoftwareTitle.SoftwarePackage.Status.PendingInstall) + require.NotEqual(t, uploadedAt, titleResponse.SoftwareTitle.SoftwarePackage.UploadedAt) + + // install should be nulled out + hostResp = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &hostResp) + require.Nil(t, hostResp.Software[0].Status) +} + +func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPoliciesAssociated() { + ctx := context.Background() + t := s.T() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + policy1Team1, err := s.ds.NewTeamPolicy( + ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "team1Policy1", + Query: "SELECT 1;", + }, + ) + require.NoError(t, err) + policy2Team2, err := s.ds.NewTeamPolicy( + ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "team2Policy2", + Query: "SELECT 2;", + }, + ) + require.NoError(t, err) + + // create an HTTP server to host software installers + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var fileName string + switch r.URL.Path { + case "/ruby.deb", "/dummy_installer.pkg": + fileName = strings.TrimPrefix(r.URL.Path, "/") + default: + w.WriteHeader(http.StatusNotFound) + return + } + file, err := os.Open(filepath.Join("testdata", "software-installers", fileName)) + require.NoError(t, err) + defer file.Close() + w.Header().Set("Content-Type", "application/vnd.debian.binary-package") + _, err = io.Copy(w, file) + require.NoError(t, err) + }) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + // team1 has ruby.deb + softwareToInstall := []fleet.SoftwareInstallerPayload{ + { + URL: srv.URL + "/ruby.deb", + }, + } + var batchResponse batchSetSoftwareInstallersResponse + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", team1.Name) + packages := waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, team1.ID, *packages[0].TeamID) + require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL) + + // team2 has dummy_installer.pkg and ruby.deb. + softwareToInstall = []fleet.SoftwareInstallerPayload{ + { + URL: srv.URL + "/dummy_installer.pkg", + }, + { + URL: srv.URL + "/ruby.deb", + }, + } + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", team2.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, team2.Name, batchResponse.RequestUUID) + sort.Slice(packages, func(i, j int) bool { + return packages[i].URL < packages[j].URL + }) + require.Len(t, packages, 2) + require.NotNil(t, packages[0].TitleID) + require.NotNil(t, packages[0].TeamID) + require.Equal(t, team2.ID, *packages[0].TeamID) + require.Equal(t, srv.URL+"/dummy_installer.pkg", packages[0].URL) + require.NotNil(t, packages[1].TitleID) + require.NotNil(t, packages[1].TeamID) + require.Equal(t, team2.ID, *packages[1].TeamID) + require.Equal(t, srv.URL+"/ruby.deb", packages[1].URL) + + // Associate ruby.deb to policy1Team1. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // Associate ruby.deb in team2 to policy2Team2. + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy2Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // Get rid of all installers in team1. + softwareToInstall = []fleet.SoftwareInstallerPayload{} + s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, &batchResponse, "team_name", team1.Name) + packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID) + require.Len(t, packages, 0) + + // policy1Team1 should not be associated to any installer. + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.SoftwareInstallerID) + // team1 should be empty. + titlesResp := listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(team1.ID))) + require.Equal(t, 0, titlesResp.Count) + + // team2 should be untouched. + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(team2.ID))) + require.Equal(t, 2, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 2) + require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.Equal(t, srv.URL+"/dummy_installer.pkg", *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.NotNil(t, titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL) + require.Equal(t, srv.URL+"/ruby.deb", *titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL) + + // policy2Team2 should still be associated to ruby.deb of team2. + policy2Team2, err = s.ds.Policy(ctx, policy2Team2.ID) + require.NoError(t, err) + require.NotNil(t, policy2Team2.SoftwareInstallerID) } func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() { @@ -10379,6 +11455,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP } scriptContentID, _ := res.LastInsertId() + uninstallScript := fmt.Sprintf(`echo uninstall '%s'`, kind) + resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, + uninstallScript, uninstallScript) + if err != nil { + return err + } + uninstallScriptContentID, _ := resUninstall.LastInsertId() + res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', ?)`, kind) if err != nil { return err @@ -10388,10 +11472,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP _, err = q.ExecContext(ctx, ` INSERT INTO software_installers - (title_id, filename, version, install_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) + (title_id, filename, extension, version, install_script_content_id, uninstall_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) VALUES - (?, ?, ?, ?, unhex(?), ?, ?, ?)`, - titleID, fmt.Sprintf("installer.%s", kind), "v1.0.0", scriptContentID, hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo") + (?, ?, ?, ?, ?, ?, unhex(?), ?, ?, ?)`, + titleID, fmt.Sprintf("installer.%s", kind), kind, "v1.0.0", scriptContentID, uninstallScriptContentID, + hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo") return err }) } @@ -10414,7 +11499,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP } var resp installSoftwareResponse - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", host.ID, softwareTitles[kind]), nil, wantStatus, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, softwareTitles[kind]), nil, + wantStatus, &resp) } } } @@ -10423,6 +11509,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { t := s.T() + // Enabling software inventory globally, which will be inherited by the team + appConf, err := s.ds.AppConfig(context.Background()) + require.NoError(s.T(), err) + appConf.Features.EnableSoftwareInventory = true + err = s.ds.SaveAppConfig(context.Background(), appConf) + require.NoError(s.T(), err) + time.Sleep(2 * time.Second) // Wait for the app config cache to clear + var createTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ Name: t.Name(), @@ -10432,7 +11526,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { var resp installSoftwareResponse // non-existent host - s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/install/1", nil, http.StatusNotFound, &resp) + s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/install", nil, http.StatusNotFound, &resp) + s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/uninstall", nil, http.StatusNotFound, &resp) // create a host that doesn't have fleetd installed h, err := s.ds.NewHost(context.Background(), &fleet.Host{ @@ -10452,29 +11547,65 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { // request fails resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusUnprocessableEntity, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusUnprocessableEntity, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusUnprocessableEntity, &resp) // host installs fleetd - setOrbitEnrollment(t, h, s.ds) + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey // request fails because of non-existent title resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusBadRequest, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusBadRequest, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusBadRequest, &resp) payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "another install script", PreInstallQuery: "another pre install query", PostInstallScript: "another post install script", + UninstallScript: "another uninstall script with $PACKAGE_ID", Filename: "ruby.deb", Title: "ruby", TeamID: teamID, } s.uploadSoftwareInstaller(payload, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + + // Get title with software installer + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", *teamID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, `another uninstall script with "ruby"`, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Upload another package for another platform + payloadDummy := &fleet.UploadSoftwareInstallerPayload{ + Filename: "dummy_installer.pkg", + Title: "DummyApp.app", + TeamID: teamID, + } + s.uploadSoftwareInstaller(payloadDummy, http.StatusOK, "") + pkgTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps") + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", pkgTitleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", *teamID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + assert.NotContains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "$PACKAGE_ID") + assert.Contains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "com.example.dummy") + + // install/uninstall request fails for the wrong platform + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp) + + // delete software installer which we will not use + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", pkgTitleID), nil, http.StatusNoContent, + "team_id", fmt.Sprintf("%d", *teamID)) // install script request succeeds - titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusAccepted, &resp) // Get the results, should be pending getHostSoftwareResp := getHostSoftwareResponse{} @@ -10483,16 +11614,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage) require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) require.NotNil(t, getHostSoftwareResp.Software[0].Status) - require.Equal(t, fleet.SoftwareInstallerPending, *getHostSoftwareResp.Software[0].Status) + require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status) + assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall) installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID gsirr := getSoftwareInstallResultsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr) require.NoError(t, gsirr.Err) require.NotNil(t, gsirr.Results) results := gsirr.Results require.Equal(t, installUUID, results.InstallUUID) - require.Equal(t, fleet.SoftwareInstallerPending, results.Status) + require.Equal(t, fleet.SoftwareInstallPending, results.Status) + + // Can't install/uninstall if software install is pending + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp) // create 3 more hosts, will have statuses installed, failed and one with two // install requests - one failed and the latest install pending @@ -10502,7 +11638,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h2.ID, h3.ID, h4.ID}) require.NoError(t, err) - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h2.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h2.ID, titleID), nil, http.StatusAccepted, &resp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp) require.Len(t, getHostSoftwareResp.Software, 1) installUUID2 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID @@ -10514,7 +11650,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { "install_script_output": "ok" }`, *h2.OrbitNodeKey, installUUID2)), http.StatusNoContent) - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h3.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h3.ID, titleID), nil, http.StatusAccepted, &resp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h3.ID), nil, http.StatusOK, &getHostSoftwareResp) require.Len(t, getHostSoftwareResp.Software, 1) installUUID3 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID @@ -10526,7 +11662,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { "install_script_output": "failed" }`, *h3.OrbitNodeKey, installUUID3)), http.StatusNoContent) - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h4.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp) require.Len(t, getHostSoftwareResp.Software, 1) installUUID4a := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID @@ -10536,7 +11672,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { "pre_install_condition_output": "" }`, *h4.OrbitNodeKey, installUUID4a)), http.StatusNoContent) - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h4.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp) s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp) require.Len(t, getHostSoftwareResp.Software, 1) installUUID4b := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID @@ -10552,9 +11688,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) require.Equal(t, fleet.SoftwareInstallerStatusSummary{ - Installed: 1, - Pending: 2, - Failed: 1, + Installed: 1, + PendingInstall: 2, + FailedInstall: 1, }, *titleResp.SoftwareTitle.SoftwarePackage.Status) // status is reflected in list hosts responses and counts when filtering by software title and status @@ -10620,7 +11756,145 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_id", "1") require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.") - // Access software install result after host is deleted + // Return installed app with software detail query + distributedReq := submitDistributedQueryResultsRequestShim{ + NodeKey: *h2.NodeKey, + Results: map[string]json.RawMessage{ + hostDetailQueryPrefix + "software_linux": json.RawMessage(fmt.Sprintf( + `[{"name": "%s", "version": "1.0", "type": "Package (deb)", + "source": "deb_packages", "last_opened_at": "", + "installed_path": "/bin/ruby"}]`, payload.Title)), + }, + Statuses: map[string]interface{}{ + hostDistributedQueryPrefix + "software_linux": 0, + }, + Messages: map[string]string{}, + Stats: map[string]*fleet.Stats{}, + } + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + assert.NotNil(t, getHostSoftwareResp.Software[0].Status) + assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) + assert.NotEmpty(t, getHostSoftwareResp.Software[0].InstalledVersions, "Installed versions should exist") + + // Remove the installed app by not returning it + distributedReq = submitDistributedQueryResultsRequestShim{ + NodeKey: *h2.NodeKey, + Results: map[string]json.RawMessage{ + hostDetailQueryPrefix + "software_linux": json.RawMessage(`[]`), + }, + Statuses: map[string]interface{}{ + hostDistributedQueryPrefix + "software_linux": 0, + }, + Messages: map[string]string{}, + Stats: map[string]*fleet.Stats{}, + } + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + assert.Nil(t, getHostSoftwareResp.Software[0].Status) + assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) + assert.Empty(t, getHostSoftwareResp.Software[0].InstalledVersions, "Installed versions should now not exist") + + // Mark original install successful + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent) + + // Do uninstall + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) + assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status) + require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall) + uninstallExecutionID := getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID + + // Uninstall should show up as a pending activity + var listUpcomingAct listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", h.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 1) + assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), listUpcomingAct.Activities[0].Type) + details := make(map[string]interface{}, 5) + require.NoError(t, json.Unmarshal(*listUpcomingAct.Activities[0].Details, &details)) + assert.EqualValues(t, fleet.SoftwareUninstallPending, details["status"]) + + // Check that status is reflected in software title response + titleResp = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", + strconv.Itoa(int(*teamID))) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) + assert.Equal(t, fleet.SoftwareInstallerStatusSummary{ + PendingInstall: 1, + FailedInstall: 1, + PendingUninstall: 1, + }, *titleResp.SoftwareTitle.SoftwarePackage.Status) + + // Another install/uninstall cannot be send once an uninstall is pending + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp) + + // Host sends successful uninstall result + var orbitPostScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey, + uninstallExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + // Check activity feed + var activitiesResp listActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id", + "order_direction", "desc") + require.NotEmpty(t, activitiesResp.Activities) + assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type) + details = make(map[string]interface{}, 5) + require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details)) + assert.Equal(t, "uninstalled", details["status"]) + + // Software should be available for install again + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) + require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall) + assert.Nil(t, getHostSoftwareResp.Software[0].Status) + + // Uninstall again, but this time with a failed result + beforeUninstall := time.Now() + // Since host_script_results does not use fine-grained timestamps yet, we adjust + beforeUninstall = beforeUninstall.Add(-time.Second) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall) + assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status) + require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall) + uninstallExecutionID = getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID + // Host sends failed uninstall result + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 1, "output": "not ok"}`, *h.OrbitNodeKey, + uninstallExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + // Check activity feed + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id", + "order_direction", "desc") + require.NotEmpty(t, activitiesResp.Activities) + assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type) + details = make(map[string]interface{}, 5) + require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details)) + assert.Equal(t, "failed", details["status"]) + + // Access software install/uninstall result after host is deleted err = s.ds.DeleteHost(context.Background(), h.ID) require.NoError(t, err) @@ -10629,12 +11903,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.NotNil(t, instResult.HostDeletedAt) gsirr = getSoftwareInstallResultsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr) require.NoError(t, gsirr.Err) require.NotNil(t, gsirr.Results) results = gsirr.Results require.Equal(t, installUUID, results.InstallUUID) - require.Equal(t, fleet.SoftwareInstallerPending, results.Status) + require.Equal(t, fleet.SoftwareInstalled, results.Status) + + var scriptResultResp getScriptResultResponse + s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+uninstallExecutionID, nil, http.StatusOK, &scriptResultResp) + assert.Equal(t, h.ID, scriptResultResp.HostID) + assert.NotEmpty(t, scriptResultResp.ScriptContents) + require.NotNil(t, scriptResultResp.ExitCode) + assert.EqualValues(t, 1, *scriptResultResp.ExitCode) + assert.Equal(t, "not ok", scriptResultResp.Output) + assert.Less(t, beforeUninstall, scriptResultResp.CreatedAt) } func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { @@ -10686,7 +11969,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { require.Equal(t, host1.ID, details.HostID) require.Equal(t, details.SoftwareTitle, payloadSS.Title) require.True(t, details.SelfService) - require.EqualValues(t, fleet.SoftwareInstallerPending, details.Status) + require.EqualValues(t, fleet.SoftwareInstallPending, details.Status) installID := details.InstallUUID // record the installation results @@ -10716,7 +11999,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() { require.Equal(t, host1.ID, details.HostID) require.Equal(t, details.SoftwareTitle, payloadSS.Title) require.True(t, details.SelfService) - require.EqualValues(t, fleet.SoftwareInstallerInstalled, details.Status) + require.EqualValues(t, fleet.SoftwareInstalled, details.Status) } func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { @@ -10725,7 +12008,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { host := createOrbitEnrolledHost(t, "linux", "", s.ds) - // create a software installer and some host install requests + // Create software installers and corresponding host install requests. payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install script", PreInstallQuery: "pre install query", @@ -10735,6 +12018,24 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } s.uploadSoftwareInstaller(payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + payload2 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 2", + PreInstallQuery: "pre install query 2", + PostInstallScript: "post install script 2", + Filename: "vim.deb", + Title: "vim", + } + s.uploadSoftwareInstaller(payload2, http.StatusOK, "") + titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages") + payload3 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 3", + PreInstallQuery: "pre install query 3", + PostInstallScript: "post install script 3", + Filename: "emacs.deb", + Title: "emacs", + } + s.uploadSoftwareInstaller(payload3, http.StatusOK, "") + titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages") latestInstallUUID := func() string { var id string @@ -10745,10 +12046,12 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } // create some install requests for the host + beforeInstall := time.Now() installUUIDs := make([]string, 3) + titleIDs := []uint{titleID, titleID2, titleID3} for i := 0; i < len(installUUIDs); i++ { resp := installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp) installUUIDs[i] = latestInstallUUID() } @@ -10762,7 +12065,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } checkResults := func(want result) { var resp getSoftwareInstallResultsResponse - s.DoJSON("GET", "/api/v1/fleet/software/install/results/"+want.InstallUUID, nil, http.StatusOK, &resp) + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", want.InstallUUID), nil, http.StatusOK, &resp) assert.Equal(t, want.HostID, resp.Results.HostID) assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID) @@ -10770,6 +12073,8 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { assert.Equal(t, want.PreInstallQueryOutput, resp.Results.PreInstallQueryOutput) assert.Equal(t, want.Output, resp.Results.Output) assert.Equal(t, want.PostInstallScriptOutput, resp.Results.PostInstallScriptOutput) + assert.Less(t, beforeInstall, resp.Results.CreatedAt) + assert.Greater(t, time.Now(), resp.Results.CreatedAt) } s.Do("POST", "/api/fleet/orbit/software_install/result", @@ -10784,7 +12089,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { checkResults(result{ HostID: host.ID, InstallUUID: installUUIDs[0], - Status: fleet.SoftwareInstallerFailed, + Status: fleet.SoftwareInstallFailed, PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy), Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallFailCopy, "failed")), }) @@ -10794,7 +12099,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { SoftwareTitle: payload.Title, SoftwarePackage: payload.Filename, InstallUUID: installUUIDs[0], - Status: string(fleet.SoftwareInstallerFailed), + Status: string(fleet.SoftwareInstallFailed), } s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) @@ -10808,10 +12113,17 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { checkResults(result{ HostID: host.ID, InstallUUID: installUUIDs[1], - Status: fleet.SoftwareInstallerFailed, + Status: fleet.SoftwareInstallFailed, PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy), }) - wantAct.InstallUUID = installUUIDs[1] + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload2.Title, + SoftwarePackage: payload2.Filename, + InstallUUID: installUUIDs[1], + Status: string(fleet.SoftwareInstallFailed), + } s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) s.Do("POST", "/api/fleet/orbit/software_install/result", @@ -10828,13 +12140,19 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { checkResults(result{ HostID: host.ID, InstallUUID: installUUIDs[2], - Status: fleet.SoftwareInstallerInstalled, + Status: fleet.SoftwareInstalled, PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy), Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")), PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")), }) - wantAct.InstallUUID = installUUIDs[2] - wantAct.Status = string(fleet.SoftwareInstallerInstalled) + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload3.Title, + SoftwarePackage: payload3.Filename, + InstallUUID: installUUIDs[2], + Status: string(fleet.SoftwareInstalled), + } lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) // non-existing installation uuid @@ -10952,7 +12270,11 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { require.EqualValues(t, 0, *scriptRes.ExitCode) } -func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) { +func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller( + payload *fleet.UploadSoftwareInstallerPayload, + expectedStatus int, + expectedError string, +) { t := s.T() t.Helper() openFile := func(name string) *os.File { @@ -10984,6 +12306,7 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. require.NoError(t, w.WriteField("install_script", payload.InstallScript)) require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) + require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript)) if payload.SelfService { require.NoError(t, w.WriteField("self_service", "true")) } @@ -10997,6 +12320,8 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. } r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) + defer r.Body.Close() + if expectedError != "" { errMsg := extractServerErrorText(r.Body) require.Contains(t, errMsg, expectedError) @@ -11320,6 +12645,21 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { ) } +func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some installer script", + Filename: "no_version.pkg", + TeamID: &team.ID, + } + s.uploadSoftwareInstaller(payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, matches existing software title @@ -11659,11 +12999,15 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NotZero(t, event.StartTime) require.NotZero(t, event.EndTime) require.NotEmpty(t, event.UUID) + bodyTag := event.GetBodyTag() + assert.NotEmpty(t, bodyTag) assert.Equal(t, 1, calendar.MockChannelsCount()) // Get channel ID type eventDetails struct { ChannelID string `json:"channel_id"` + BodyTag string `json:"body_tag"` + ETag string `json:"etag"` } var details eventDetails err = json.Unmarshal(event.Data, &details) @@ -11693,7 +13037,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { // Grab the distributed lock for this event distributedLock := redis_lock.NewLock(s.redisPool) lockValue := uuid.New().String() - result, err := distributedLock.AcquireLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0) + result, err := distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0) require.NoError(t, err) assert.NotEmpty(t, result) @@ -11717,7 +13061,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { ok, err := distributedLock.ReleaseLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue) require.NoError(t, err) assert.True(t, ok) - result, err = distributedLock.AcquireLock(ctx, commonCalendar.ReservedLockKeyPrefix+event.UUID, lockValue, 0) + result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.ReservedLockKeyPrefix+event.UUID, lockValue, 0) require.NoError(t, err) assert.NotEmpty(t, result) @@ -11735,7 +13079,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { // We grab the normal lock again. lockValue2 := uuid.New().String() - result, err = distributedLock.AcquireLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue2, 0) + result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue2, 0) require.NoError(t, err) assert.NotEmpty(t, result) // We release the reserve lock. @@ -11786,6 +13130,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { err = json.Unmarshal(eventRecreated.Data, &details) require.NoError(t, err) + assert.NotEmpty(t, details.BodyTag) + bodyTag = details.BodyTag // New event callback should work _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, @@ -11822,6 +13168,30 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { assert.Greater(t, eventUpdated.StartTime, eventRecreated.StartTime) assert.Equal(t, eventRecreated.EndTime, eventUpdated.EndTime) assert.Equal(t, 1, calendar.MockChannelsCount()) + assert.Equal(t, bodyTag, eventRecreated.GetBodyTag()) + + // Change the body contents of event. + events = calendar.ListGoogleMockEvents() + require.Len(t, events, 1) + eTag := "description change etag" + for _, e := range events { + e.Etag = eTag + e.Description = "new description" + } + // New event callback should cause Etag to update but Body tag to remain the same + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, + map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + eventDescUpdated := team1CalendarEvents[0] + err = json.Unmarshal(eventDescUpdated.Data, &details) + require.NoError(t, err) + assert.Equal(t, bodyTag, details.BodyTag) + assert.Equal(t, eTag, details.ETag) // Update the time of the event again events = calendar.ListGoogleMockEvents() @@ -11831,12 +13201,13 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NoError(t, err) newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339) e.Start.DateTime = newStartTime + e.Etag = e.Etag + "1" } // Grab the lock event = eventUpdated lockValue = uuid.New().String() - result, err = distributedLock.AcquireLock(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0) + result, err = distributedLock.SetIfNotExist(ctx, commonCalendar.LockKeyPrefix+event.UUID, lockValue, 0) require.NoError(t, err) assert.NotEmpty(t, result) @@ -11881,6 +13252,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NoError(t, err) if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID && team1CalendarEvents[0].StartTime.After(event.StartTime) { + err = json.Unmarshal(team1CalendarEvents[0].Data, &details) + require.NoError(t, err) + assert.NotEqual(t, eTag, details.ETag, "ETag should have updated") done <- struct{}{} return } @@ -11906,7 +13280,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { ), http.StatusOK, &distributedResp) // We set a flag that event was updated recently. Callback shouldn't do anything since event was updated recently - _, err = distributedLock.AcquireLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue, + _, err = distributedLock.SetIfNotExist(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue, 1000) require.NoError(t, err) _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, @@ -12243,7 +13617,6 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEventBodyUpdate() { require.Len(t, calEvents, 1) assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultDescription) assert.Contains(t, calEvents[0].Description, fleet.CalendarDefaultResolution) - } func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { @@ -12253,6 +13626,8 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { // Create host orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) + test.CreateInsertGlobalVPPToken(t, s.ds) + // Create team and add host to team var newTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) @@ -12264,13 +13639,730 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { app, err := s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ Name: "App " + t.Name(), BundleIdentifier: "bid_" + t.Name(), - VPPAppID: fleet.VPPAppID{ - AdamID: "adam_" + t.Name(), - Platform: fleet.MacOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "adam_" + t.Name(), + Platform: fleet.MacOSPlatform, + }, }, }, &team.ID) require.NoError(t, err) - r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) + r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, + http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.") } + +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() { + t := s.T() + ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, s.ds) + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) + require.NoError(t, err) + + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: platform, + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + h := newHost(name, teamID, platform) + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey + return h + } + + host0NoTeam := newFleetdHost("host1NoTeam", nil, "darwin") + host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu") + host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows") + hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin") + + // Upload dummy_installer.pkg to team1. + pkgPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some pkg install script", + Filename: "dummy_installer.pkg", + TeamID: &team1.ID, + } + s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "DummyApp.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + dummyInstallerPkgTitleID := resp.SoftwareTitles[0].ID + var dummyInstallerPkg struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &dummyInstallerPkg, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "dummy_installer.pkg", + ) + }) + dummyInstallerPkgInstallerID := dummyInstallerPkg.ID + require.NotZero(t, dummyInstallerPkgInstallerID) + require.NotNil(t, dummyInstallerPkg.UserID) + globalAdmin, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + require.Equal(t, globalAdmin.ID, *dummyInstallerPkg.UserID) + require.Equal(t, "Test Name admin1@example.com", dummyInstallerPkg.UserName) + require.Equal(t, "admin1@example.com", dummyInstallerPkg.UserEmail) + + // Upload ruby.deb to team1 by a user who will be deleted. + u2 := &fleet.User{ + Name: "admin team1", + Email: "admin_team1@example.com", + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team1, + Role: fleet.RoleAdmin, + }, + }, + } + require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10)) + adminTeam1, err := s.ds.NewUser(context.Background(), u2) + require.NoError(t, err) + rubyPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: &team1.ID, + } + sessionKey := uuid.New().String() + adminTeam1Session, err := s.ds.NewSession(ctx, adminTeam1.ID, sessionKey) + require.NoError(t, err) + adminToken := s.token + t.Cleanup(func() { + s.token = adminToken + }) + s.token = adminTeam1Session.Key + s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "") + s.token = adminToken + err = s.ds.DeleteUser(ctx, adminTeam1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + var rubyDeb struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &rubyDeb, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "ruby.deb", + ) + }) + rubyDebInstallerID := rubyDeb.ID + require.NotZero(t, rubyDebInstallerID) + require.Nil(t, rubyDeb.UserID) + require.Equal(t, "admin team1", rubyDeb.UserName) + require.Equal(t, "admin_team1@example.com", rubyDeb.UserEmail) + + // Upload fleet-osquery.msi to team2. + fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some msi install script", + Filename: "fleet-osquery.msi", + TeamID: &team2.ID, + // Set as Self-service to check that the generated host_software_installs + // is generated with self_service=false and the activity has the correct + // author (the admin that uploaded the installer). + SelfService: true, + } + s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Fleet osquery", + "team_id", fmt.Sprintf("%d", team2.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + fleetOsqueryMSITitleID := resp.SoftwareTitles[0].ID + var fleetOsqueryMSIInstallerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &fleetOsqueryMSIInstallerID, + `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team2.ID, "fleet-osquery.msi", + ) + }) + require.NotZero(t, fleetOsqueryMSIInstallerID) + + // Create a VPP app to test that policies cannot be assigned to them. + _, err = s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "App123 " + t.Name(), + BundleIdentifier: "bid_" + t.Name(), + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "adam_" + t.Name(), + Platform: fleet.MacOSPlatform, + }, + }, + }, &team1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded VPP app. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "App123", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].AppStoreApp) + vppAppTitleID := resp.SoftwareTitles[0].ID + + // Populate software for host1Team1 (to have a software title + // that doesn't have an associated installer) + software := []fleet.Software{ + {Name: "Foobar.app", Version: "0.0.1", Source: "apps"}, + } + _, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software) + require.NoError(t, err) + require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now())) + // Get software title ID of the software. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Foobar.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.Nil(t, resp.SoftwareTitles[0].SoftwarePackage) + foobarAppTitleID := resp.SoftwareTitles[0].ID + + // policy0AllTeams is a global policy that runs on all devices. + policy0AllTeams, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "policy0AllTeams", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy1Team1 runs on macOS devices. + policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy2Team1 runs on macOS and Linux devices. + policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy2Team1", + Query: "SELECT 2;", + Platform: "linux,darwin", + }) + require.NoError(t, err) + // policy3Team1 runs on all devices in team1 (will have no associated installers). + policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy3Team1", + Query: "SELECT 3;", + }) + require.NoError(t, err) + // policy4Team2 runs on Windows devices. + policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + Platform: "windows", + }) + require.NoError(t, err) + + // Attempt to associate to an unknown software title. + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(999_999), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate to a software title without associated installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(foobarAppTitleID), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate vppApp to policy1Team1 which should fail because we only allow associating software installers. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &vppAppTitleID, + }, + }, http.StatusBadRequest, &mtplr) + // Associate dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + // Change name only (to test not setting a software_title_id). + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), + json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr, + ) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + require.Equal(t, "policy1Team1_Renamed", *&policy1Team1.Name) + // Explicit set to 0 to disable. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.SoftwareInstallerID) + + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Add some results and stats that should be cleared after setting an installer again. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + + // Back to associating dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Add (again) some results and stats that should be cleared after changing an existing installer. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + + // Change the installer (temporarily to test that changing an installer will clear results) + // Associate ruby.deb to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // After changing the installer, membership and stats should be cleared. + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, rubyDebInstallerID, *policy1Team1.SoftwareInstallerID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Back to (again) associating dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + + // Associate ruby.deb to policy2Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the + // current user's "Authorization: Bearer " header. + + // host1Team1 fails all policies on the first report. + // Failing policy1Team1 means an install request must be generated. + // Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts). + // Failing policy3Team1 should do nothing because it doesn't have any installers associated to it. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.NotEmpty(t, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + prevExecutionID := host1LastInstall.ExecutionID + + // Request a manual installation on the host for the same installer, which should fail. + var installResp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", + host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp) + + // Submit same results as before, which should not trigger a installation because the policy is already failing. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + + // Submit same results but policy1Team1 now passes, + // and then submit again but policy1Team1 fails. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(true), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Another installation should not be triggered because the last installation is pending. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status) + + // host2Team1 is failing policy2Team1 and policy3Team1 policies. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host2Team1, + map[uint]*bool{ + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host2LastInstall, err := s.ds.GetHostLastInstallData(ctx, host2Team1.ID, rubyDebInstallerID) + require.NoError(t, err) + require.NotNil(t, host2LastInstall) + require.NotEmpty(t, host2LastInstall.ExecutionID) + require.NotNil(t, host2LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host2LastInstall.Status) + + // Associate fleet-osquery.msi to policy4Team2. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &fleetOsqueryMSITitleID, + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2, which should trigger an installation. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host3LastInstall, err := s.ds.GetHostLastInstallData(ctx, host3Team2.ID, fleetOsqueryMSIInstallerID) + require.NoError(t, err) + require.NotNil(t, host3LastInstall) + require.NotEmpty(t, host3LastInstall.ExecutionID) + require.NotNil(t, host3LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallPending, *host3LastInstall.Status) + host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID) + require.NoError(t, err) + // Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so + // host3LastInstallDetails.SelfService should be false. + require.False(t, host3LastInstallDetails.SelfService) + + // + // The following increase coverage of policies result processing in distributed/write. + // + + // host3Team2 reports a passing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(true), + }, + ), http.StatusOK, &distributedResp) + + // host0NoTeam reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host0NoTeam, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // host3Team2 reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Unassociate policy4Team2 from installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Upcoming activities for host1Team1 should show the automatic installation of dummy_installer.pkg. + // Check the author should be the admin that uploaded the installer. + var listUpcomingAct listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1Team1.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 1) + require.NotNil(t, listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.ID, *listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.Name, *listUpcomingAct.Activities[0].ActorFullName) + require.Equal(t, globalAdmin.Email, *listUpcomingAct.Activities[0].ActorEmail) + + // + // Finally have orbit install the packages and check activities. + // + + // host1Team1 posts the installation result for dummy_installer.pkg. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent) + s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "installed" + }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp.app", "dummy_installer.pkg", host1LastInstall.ExecutionID), 0) + + // host2Team1 posts the installation result for ruby.deb. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host2Team1.OrbitNodeKey, host2LastInstall.ExecutionID)), http.StatusNoContent) + activityID := s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "%s" + }`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID, fleet.SoftwareInstallFailed), 0) + + // Check that the activity item generated for ruby.deb installation has a null user, + // but has name and email set. + var actor struct { + UserID *uint `db:"user_id"` + UserName *string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.Nil(t, actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "admin team1", *actor.UserName) + require.Equal(t, "admin_team1@example.com", actor.UserEmail) + + // host3Team2 posts the installation result for fleet-osquery.msi. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host3Team2.OrbitNodeKey, host3LastInstall.ExecutionID)), http.StatusNoContent) + activityID = s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "%s" + }`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID, + fleet.SoftwareInstallFailed), 0) + + // Check that the activity item generated for fleet-osquery.msi installation has the admin user set as author. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.NotNil(t, actor.UserID) + require.Equal(t, globalAdmin.ID, *actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "Test Name admin1@example.com", *actor.UserName) + require.Equal(t, "admin1@example.com", actor.UserEmail) + + // hostVanillaOsquery5Team1 sends policy results with failed policies with associated installers. + // Fleet should not queue an install for vanilla osquery hosts. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + hostVanillaOsquery5Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + hostVanillaOsquery5Team1LastInstall, err := s.ds.GetHostLastInstallData(ctx, hostVanillaOsquery5Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, hostVanillaOsquery5Team1LastInstall) +} diff --git a/server/service/integration_live_queries_test.go b/server/service/integration_live_queries_test.go index b5ba57ae68..7d3cfef7f1 100644 --- a/server/service/integration_live_queries_test.go +++ b/server/service/integration_live_queries_test.go @@ -31,7 +31,7 @@ import ( "github.com/stretchr/testify/suite" ) -func TestIntegrationLiveQueriesTestSuite(t *testing.T) { +func TestIntegrationsLiveQueriesTestSuite(t *testing.T) { testingSuite := new(liveQueriesTestSuite) testingSuite.withServer.s = &testingSuite.Suite suite.Run(t, testingSuite) diff --git a/server/service/integration_logger_test.go b/server/service/integration_logger_test.go index 8831d2e304..6a6f7be931 100644 --- a/server/service/integration_logger_test.go +++ b/server/service/integration_logger_test.go @@ -22,9 +22,9 @@ import ( "github.com/stretchr/testify/suite" ) -func TestIntegrationLoggerTestSuite(t *testing.T) { +func TestIntegrationsLoggerTestSuite(t *testing.T) { testingSuite := new(integrationLoggerTestSuite) - testingSuite.s = &testingSuite.Suite + testingSuite.withDS.s = &testingSuite.Suite suite.Run(t, testingSuite) } diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index f23b1858e4..95bd4a5b35 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -39,6 +39,19 @@ type profileAssignmentReq struct { func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { t := s.T() ctx := context.Background() + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo"))) + case "/profile": + w.WriteHeader(http.StatusOK) + require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) + } + })) globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} @@ -94,6 +107,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { // enable FileVault s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage([]byte(`{"mdm":{"macos_settings":{"enable_disk_encryption":true}}}`)), http.StatusOK) + s.enableABM("fleet_ade_test") for _, enableReleaseManually := range []bool{false, true} { t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) { s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1") @@ -105,6 +119,20 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { t := s.T() ctx := context.Background() + // Set up a mock DEP Apple API + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo"))) + case "/profile": + require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) + } + })) + teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-team-device-release"}) @@ -137,9 +165,15 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { // setup IdP so that AccountConfiguration profile is sent after DEP enrollment var acResp appConfigResponse + s.enableABM("fleet_ade_test") s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ "mdm": { - "apple_bm_default_team": %q, + "apple_business_manager": [{ + "organization_name": %q, + "macos_team": %q, + "ios_team": %q, + "ipados_team": %q + }], "end_user_authentication": { "entity_id": "https://localhost:8080", "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", @@ -150,7 +184,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { "enable_end_user_authentication": true } } - }`, tm.Name)), http.StatusOK, &acResp) + }`, "fleet_ade_test", tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp) require.NotEmpty(t, acResp.MDM.EndUserAuthentication) // TODO(mna): how/where to pass an enroll_reference so that @@ -191,7 +225,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de return map[string]*push.Response{}, nil } - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse("fleet_ade_test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { encoder := json.NewEncoder(w) switch r.URL.Path { case "/session": @@ -537,7 +571,9 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { 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) { + + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -919,6 +955,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runWorker() // expect no assign profile request during cooldown profileAssignmentReqs = []profileAssignmentReq{} s.runIntegrationsSchedule() @@ -985,7 +1022,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) - // run the inregration schedule and expect success + // run the integration schedule and expect success expectAssignProfileResponseFailed = "" profileAssignmentReqs = []profileAssignmentReq{} s.runIntegrationsSchedule() @@ -1121,3 +1158,53 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { s.runDEPSchedule() require.Empty(t, profileAssignmentReqs) } + +func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() { + t := s.T() + + s.enableABM(t.Name()) + + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(s.T(), err) + + var acResp appConfigResponse + + defer func() { + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_bm_default_team": "" + } + }`), http.StatusOK, &acResp) + require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam) + }() + + // try to set an invalid team name + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_bm_default_team": "xyz" + } + }`), http.StatusUnprocessableEntity, &acResp) + + // get the appconfig, nothing changed + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam) + + // set to a valid team name + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ + "mdm": { + "apple_bm_default_team": %q + } + }`, tm.Name)), http.StatusOK, &acResp) + require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam) + + // get the appconfig, set to that team name + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.Equal(t, tm.Name, acResp.MDM.DeprecatedAppleBMDefaultTeam) +} diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 5dce43108d..27c1458c40 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/xml" "fmt" @@ -27,8 +28,8 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) // NOTE: the mantra for lifecycle events is: @@ -226,7 +227,8 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { t.Run("automatic enrollment", func(t *testing.T) { device := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, "") - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -590,10 +592,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { t := s.T() ctx := context.Background() // ensure there's a token for automatic enrollments - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) - })) + s.enableABM(t.Name()) s.runDEPSchedule() // add a device that's manually enrolled @@ -772,7 +771,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NoError(t, err) // set the env var, and run the cron - t.Setenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE", "") + t.Setenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE", base64.StdEncoding.EncodeToString([]byte(""))) err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) require.NoError(t, err) checkRenewCertCommand(migratedDevice, "", "") diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 318f615ad0..49a1f6eab0 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "strconv" "strings" @@ -25,13 +26,14 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.mozilla.org/pkcs7" ) func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) { @@ -949,6 +951,9 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { ctx := context.Background() t := s.T() + // before we switch to a gitops token, ensure ABM is setup + s.enableABM(t.Name()) + // Use a gitops user for all Puppet actions u := &fleet.User{ Name: "GitOps", @@ -982,7 +987,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { // create a setup assistant for no team, for this we need to: // 1. mock the ABM API, as it gets called to set the profile // 2. run the DEP schedule, as this registers the default profile - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) })) @@ -1154,18 +1159,16 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { s.awaitTriggerProfileSchedule(t) // useful for debugging - //mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - // mysql.DumpTable(t, q, "host_mdm_apple_profiles") - // return nil - //}) + // mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + // mysql.DumpTable(t, q, "host_mdm_apple_profiles") + // return nil + // }) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ mdmHost: { {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, - {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, }, }) @@ -1232,6 +1235,21 @@ func (s *integrationMDMTestSuite) TestPuppetRun() { host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) s.runWorker() + // Set up a mock Apple DEP API + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo"))) + case "/profile": + w.WriteHeader(http.StatusOK) + require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) + } + })) + // Use a gitops user for all Puppet actions u := &fleet.User{ Name: "GitOps", @@ -3779,17 +3797,18 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity items + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, @@ -4059,12 +4078,13 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, @@ -4176,10 +4196,6 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { ) } -func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() { - s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual") -} - func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() { t := s.T() ctx := context.Background() @@ -4810,3 +4826,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() { }, }) } + +func (s *integrationMDMTestSuite) TestOTAProfile() { + t := s.T() + ctx := context.Background() + + // Getting profile for non-existent secret it's ok + s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real") + + // Create an enroll secret; has some special characters that should be escaped in the profile + globalEnrollSec := "global_enroll+_/sec" + escSec := url.QueryEscape(globalEnrollSec) + s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}}, + }, + }, http.StatusOK) + + cfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + + // Get profile with that enroll secret + resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec) + require.NotZero(t, resp.ContentLength) + require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`) + require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") + require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(b))) + require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota") + require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec)) + require.Contains(t, string(b), cfg.OrgInfo.OrgName) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b4180fb2b2..ce134a2bd5 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -39,6 +39,7 @@ import ( "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" + "github.com/fleetdm/fleet/v4/server/datastore/s3" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" servermdm "github.com/fleetdm/fleet/v4/server/mdm" @@ -65,10 +66,10 @@ import ( "github.com/groob/plist" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "go.mozilla.org/pkcs7" ) func TestIntegrationsMDM(t *testing.T) { @@ -84,11 +85,9 @@ type integrationMDMTestSuite struct { 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 @@ -98,6 +97,7 @@ type integrationMDMTestSuite struct { appleVPPConfigSrv *httptest.Server appleVPPConfigSrvConfig *appleVPPConfigSrvConf appleITunesSrv *httptest.Server + appleGDMFSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata } @@ -105,6 +105,7 @@ type integrationMDMTestSuite struct { type appleVPPConfigSrvConf struct { Assets []vpp.Asset SerialNumbers []string + Location string } func (s *integrationMDMTestSuite) SetupSuite() { @@ -174,7 +175,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { return err }) - var depSchedule *schedule.Schedule var integrationsSchedule *schedule.Schedule var profileSchedule *schedule.Schedule cronLog := kitlog.NewJSONLogger(os.Stdout) @@ -185,39 +185,33 @@ func (s *integrationMDMTestSuite) SetupSuite() { if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { serverLogger = kitlog.NewNopLogger() } + + var softwareInstallerStore fleet.SoftwareInstallerStore + var bootstrapPackageStore fleet.MDMBootstrapPackageStore + _, minioEnabled := os.LookupEnv("MINIO_STORAGE_TEST") + if wantStore := os.Getenv("FLEET_INTEGRATION_TESTS_SOFTWARE_INSTALLER_STORE"); minioEnabled && + (wantStore == "s3" || (wantStore == "" && time.Now().UnixNano()%2 == 0)) { + + s.T().Log(">>> using S3/minio software installer store") + softwareInstallerStore = s3.SetupTestSoftwareInstallerStore(s.T(), "integration-tests", "") + bootstrapPackageStore = s3.SetupTestBootstrapPackageStore(s.T(), "integration-tests", "") + } + config := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, }, - Logger: serverLogger, - FleetConfig: &fleetCfg, - MDMStorage: mdmStorage, - DEPStorage: depStorage, - SCEPStorage: scepStorage, - MDMPusher: mdmPushService, - Pool: redisPool, - Lq: s.lq, + Logger: serverLogger, + FleetConfig: &fleetCfg, + MDMStorage: mdmStorage, + DEPStorage: depStorage, + SCEPStorage: scepStorage, + MDMPusher: mdmPushService, + Pool: redisPool, + Lq: s.lq, + SoftwareInstallStore: softwareInstallerStore, + BootstrapPackageStore: bootstrapPackageStore, StartCronSchedules: []TestNewScheduleFunc{ - func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { - return func() (fleet.CronSchedule, error) { - const name = string(fleet.CronAppleMDMDEPProfileAssigner) - logger := cronLog - fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger) - depSchedule = schedule.New( - ctx, name, s.T().Name(), 1*time.Hour, ds, ds, - schedule.WithLogger(logger), - schedule.WithJob("dep_syncer", func(ctx context.Context) error { - if s.onDEPScheduleDone != nil { - defer s.onDEPScheduleDone() - } - err := fleetSyncer.RunAssigner(ctx) - require.NoError(s.T(), err) - return err - }), - ) - return depSchedule, nil - } - }, func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { return func() (fleet.CronSchedule, error) { const name = string(fleet.CronMDMAppleProfileManager) @@ -274,11 +268,26 @@ func (s *integrationMDMTestSuite) SetupSuite() { return integrationsSchedule, nil } }, + func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { + return func() (fleet.CronSchedule, error) { + const name = string(fleet.CronAppleMDMIPhoneIPadRefetcher) + logger := cronLog + refetcherSchedule := schedule.New( + ctx, name, s.T().Name(), 1*time.Hour, ds, ds, + schedule.WithLogger(logger), + schedule.WithJob("cron_iphone_ipad_refetcher", func(ctx context.Context) error { + return apple_mdm.IOSiPadOSRefetch(ctx, ds, mdmCommander, logger) + }), + ) + return refetcherSchedule, nil + } + }, }, APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", } - s.scepChallenge = "scepchallenge" + // ensure all our tests support challenges with invalid XML characters + s.scepChallenge = "scepcha/> 125: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "CONFIG_NAME_INVALID"})) + case prof.ProfileName == "": + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "CONFIG_NAME_REQUIRED"})) + case len(prof.ConfigurationWebURL) > 125: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "CONFIG_URL_INVALID"})) + case len(prof.Department) > 125: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "DEPARTMENT_INVALID"})) + case len(prof.SupportPhoneNumber) > 50: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "SUPPORT_PHONE_INVALID"})) + case len(prof.SupportEmailAddress) > 250: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "SUPPORT_EMAIL_INVALID"})) + case len(prof.OrgMagic) > 256: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "MAGIC_INVALID"})) + case !prof.IsMDMRemovable && !prof.IsSupervised: + w.WriteHeader(http.StatusBadRequest) + require.NoError(t, encoder.Encode(map[string]any{"error": "FLAGS_INVALID"})) + default: + w.WriteHeader(http.StatusOK) + require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) + } + } + })) + // get for no team returns 404 var getResp getMDMAppleSetupAssistantResponse s.DoJSON("GET", "/api/latest/fleet/enrollment_profiles/automatic", nil, http.StatusNotFound, &getResp) // get for non-existing team returns 404 s.DoJSON("GET", "/api/latest/fleet/enrollment_profiles/automatic", nil, http.StatusNotFound, &getResp, "team_id", "123") - // create a setup assistant for no team - noTeamProf := `{"x": 1}` + // Profile name too long var createResp createMDMAppleSetupAssistantResponse + r := s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_too_long", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "%s"}`, strings.Repeat("a", 126))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "CONFIG_NAME_INVALID") + + // Profile name missing + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(`{"profile_name": ""}`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "CONFIG_NAME_REQUIRED") + + // Config URL invalid + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "prof_name", "configuration_web_url": "%s"}`, strings.Repeat("a", 126))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "CONFIG_URL_INVALID") + + // Department invalid + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "prof_name", "configuration_web_url": "https://example.com", "department": "%s"}`, strings.Repeat("a", 126))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "DEPARTMENT_INVALID") + + // Invalid support phone + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "prof_name", "configuration_web_url": "https://example.com", "department": "foo", "support_phone_number": "%s"}`, strings.Repeat("1", 51))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "SUPPORT_PHONE_INVALID") + + // Invalid support email + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "prof_name", "configuration_web_url": "https://example.com", "department": "foo", "support_phone_number": "555-123-4567", "support_email_address": "%s"}`, strings.Repeat("1", 251))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "SUPPORT_EMAIL_INVALID") + + // Invalid magic + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(`{"profile_name": "prof_name", "configuration_web_url": "https://example.com", "department": "foo", "support_phone_number": "555-123-4567", "support_email_address": "support@example.com", "org_magic": "%s"}`, strings.Repeat("1", 257))), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "MAGIC_INVALID") + + // Invalid flag combo + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(`{"profile_name": "prof_name", "configuration_web_url": "https://example.com", "department": "foo", "support_phone_number": "555-123-4567", "support_email_address": "support@example.com", "org_magic": "1", "is_mdm_removable": false, "is_supervised": false}`), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "FLAGS_INVALID") + + // create a setup assistant for no team + noTeamProf := fmt.Sprintf(defaultProf, "no-team") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: nil, Name: "no-team", @@ -4033,7 +4354,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { Description: "desc", }) require.NoError(t, err) - tmProf := `{"y": 1}` + tmProf := fmt.Sprintf(defaultProf, "team1") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: &tm.ID, Name: "team1", @@ -4049,7 +4370,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { fmt.Sprintf(`{"name": "team1", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0) // update no-team - noTeamProf = `{"x": 2}` + noTeamProf = fmt.Sprintf(defaultProf, "no-team2") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: nil, Name: "no-team2", @@ -4059,7 +4380,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { `{"name": "no-team2", "team_id": null, "team_name": null}`, 0) // update team - tmProf = `{"y": 2}` + tmProf = fmt.Sprintf(defaultProf, "team2") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: &tm.ID, Name: "team2", @@ -4094,7 +4415,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { // update team with only a setup assistant JSON change, should detect it // and create a new activity (name is the same) - tmProf = `{"y": 3}` + tmProf = fmt.Sprintf(defaultProf, "update") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: &tm.ID, Name: "team2", @@ -4170,7 +4491,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { Description: "desc2", }) require.NoError(t, err) - tm2Prof := `{"z": 1}` + tm2Prof := fmt.Sprintf(defaultProf, "teamB") s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: &tm2.ID, Name: "teamB", @@ -4183,6 +4504,40 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { s.Do("DELETE", "/api/latest/fleet/enrollment_profiles/automatic", nil, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) s.lastActivityMatches(fleet.ActivityTypeDeletedMacosSetupAssistant{}.ActivityName(), fmt.Sprintf(`{"name": "teamB", "team_id": %d, "team_name": %q}`, tm2.ID, tm2.Name), 0) + + // Try with a team that has no relevant ABM tokens + teamNoABM, err := s.ds.NewTeam(ctx, &fleet.Team{ + Name: t.Name() + "no_abm", + Description: "no abm", + }) + require.NoError(t, err) + // Adding another, unrelated token to the DB means that this team (which has no hosts and is not + // a default team for any token) will not have any relevant tokens and thus we don't know which + // token to use to hit the Apple APIs. + otherOrg := t.Name() + "some_other_org" + s.enableABM(otherOrg) + // mysql.CreateABMKeyCertIfNotExists(t, s.ds) + // mysql.CreateAndSetABMToken(t, s.ds, "nurv") + // err = s.depStorage.StoreConfig(ctx, "nurv", &nanodep_client.Config{BaseURL: srv.URL}) + s.mockDEPResponse(otherOrg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo"))) + case "/profile": + w.WriteHeader(http.StatusOK) + require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) + } + })) + require.NoError(t, err) + r = s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: &teamNoABM.ID, + Name: "profile_name_missing", + EnrollmentProfile: json.RawMessage(fmt.Sprintf(defaultProf, "no_abm")), + }, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), "No relevant ABM tokens found. Please set this team as a default team for an ABM token.") } // only asserts the profile identifier, status and operation (per host) @@ -4327,7 +4682,9 @@ func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint } var cfgProfs []*fleet.MDMWindowsConfigProfile mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID) + return sqlx.SelectContext(context.Background(), q, &cfgProfs, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + teamID) }) label := "exist" @@ -4657,8 +5014,9 @@ func (s *integrationMDMTestSuite) TestSSO() { mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{ SCEPChallenge: s.scepChallenge, }, "MacBookPro16,1") + s.enableABM(t.Name()) var lastSubmittedProfile *godep.Profile - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -5148,6 +5506,45 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string return s.verifyEnrollmentProfile(body, "") } +func (s *integrationMDMTestSuite) downloadAndVerifyOTAEnrollmentProfile(path string) { + t := s.T() + + resp := s.DoRaw("GET", path, nil, http.StatusOK) + rawProfile, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + require.Contains(t, resp.Header, "Content-Disposition") + require.Contains(t, resp.Header, "Content-Type") + require.Contains(t, resp.Header, "X-Content-Type-Options") + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;") + require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") + require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") + headerLen, err := strconv.Atoi(resp.Header.Get("Content-Length")) + require.NoError(t, err) + require.Equal(t, len(rawProfile), headerLen) + + p7, err := pkcs7.Parse(rawProfile) + require.NoError(t, err) + rootCA := x509.NewCertPool() + + assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ + fleet.MDMAssetCACert, + }) + require.NoError(t, err) + + require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value)) + require.NoError(t, p7.VerifyWithChain(rootCA)) + + var otaEnrollmentProfile struct { + PayloadContent struct { + URL string `plist:"URL"` + } `plist:"PayloadContent"` + } + err = plist.Unmarshal(p7.Content, &otaEnrollmentProfile) + require.NoError(t, err) + require.Contains(t, otaEnrollmentProfile.PayloadContent.URL, s.getConfig().ServerSettings.ServerURL+"/api/v1/fleet/ota_enrollment") +} + func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enrollmentRef string) *enrollmentProfile { t := s.T() var profile enrollmentProfile @@ -5196,6 +5593,8 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { "mdm": { "macos_migration": { "enable": true, "mode": "voluntary", "webhook_url": "https://example.com" } } }`), http.StatusOK, &acResp) + s.enableABM(t.Name()) + checkMigrationResponses := func(host *fleet.Host, token string) { getDesktopResp := fleetDesktopResponse{} res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK) @@ -5218,7 +5617,7 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { // simulate that the device is assigned to Fleet in ABM profileAssignmentStatusResponse := fleet.DEPAssignProfileResponseSuccess - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -5449,6 +5848,37 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration) require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile) + // simulate a device that is manually enrolled to 3rd party + err = s.ds.SetOrUpdateMDMData( + ctx, + host.ID, + false, + true, + "https://simplemdm.com", + false, + fleet.WellKnownMDMSimpleMDM, + "", + ) + require.NoError(t, err) + getDesktopResp = fleetDesktopResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK) + require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp)) + require.NoError(t, res.Body.Close()) + require.NoError(t, getDesktopResp.Err) + require.Zero(t, *getDesktopResp.FailingPolicies) + require.True(t, getDesktopResp.Notifications.NeedsMDMMigration) + require.False(t, getDesktopResp.Notifications.RenewEnrollmentProfile) + require.Equal(t, acResp.OrgInfo.OrgLogoURL, getDesktopResp.Config.OrgInfo.OrgLogoURL) + require.Equal(t, acResp.OrgInfo.OrgLogoURLLightBackground, getDesktopResp.Config.OrgInfo.OrgLogoURLLightBackground) + require.Equal(t, acResp.OrgInfo.ContactURL, getDesktopResp.Config.OrgInfo.ContactURL) + require.Equal(t, acResp.OrgInfo.OrgName, getDesktopResp.Config.OrgInfo.OrgName) + require.Equal(t, acResp.MDM.MacOSMigration.Mode, getDesktopResp.Config.MDM.MacOSMigration.Mode) + + orbitConfigResp = orbitGetConfigResponse{} + s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitConfigResp) + require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration) + require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile) + // clean up nano tables mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(context.Background(), ` @@ -7083,14 +7513,13 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { Token: uuid.New().String(), Bytes: []byte("foo"), Sha256: []byte("foo-sha256"), - })) + }, nil)) // add new setup assistant _, err := s.ds.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{ - TeamID: teamID, - Name: "bar", - ProfileUUID: uuid.New().String(), - Profile: []byte("{}"), + TeamID: teamID, + Name: "bar", + Profile: []byte("{}"), }) require.NoError(t, err) } @@ -7768,11 +8197,10 @@ func (s *integrationMDMTestSuite) runWorker() { } func (s *integrationMDMTestSuite) runDEPSchedule() { - ch := make(chan bool) - s.onDEPScheduleDone = func() { close(ch) } - _, err := s.depSchedule.Trigger() + ctx := context.Background() + fleetSyncer := apple_mdm.NewDEPService(s.ds, s.depStorage, s.logger) + err := fleetSyncer.RunAssigner(ctx) require.NoError(s.T(), err) - <-ch } func (s *integrationMDMTestSuite) runIntegrationsSchedule() { @@ -8505,18 +8933,38 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) } -func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { +func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() { t := s.T() acResp := appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + s.enableABM(t.Name()) var lastSubmittedProfile *godep.Profile - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { + case "/server/devices", "/devices/sync": + encoder := json.NewEncoder(w) + err := encoder.Encode(godep.DeviceResponse{ + Devices: []godep.Device{ + { + SerialNumber: "FAKE-1", + Model: "Mac Mini", + OS: "osx", + OpType: "added", + }, + { + SerialNumber: "FAKE-2", + Model: "Mac Mini", + OS: "osx", + OpType: "added", + }, + }, + }) + require.NoError(t, err) case "/profile": lastSubmittedProfile = &godep.Profile{} rawProfile, err := io.ReadAll(r.Body) @@ -8535,6 +8983,9 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { } })) + // run once to ingest the devices + s.runDEPSchedule() + // disable first to make sure we start in the desired state acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -8563,6 +9014,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/mdm/sso") // trying to set a custom configuration_web_url fails because end user authentication is enabled @@ -8592,6 +9044,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=") // setting a custom configuration_web_url succeeds because user authentication is disabled @@ -8604,6 +9057,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, "https://foo.example.com") // try to enable end user auth again, it fails because configuration_web_url is set @@ -8640,8 +9094,13 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { require.Len(t, applyResp.TeamIDsByName, 1) teamID := applyResp.TeamIDsByName[t.Name()] + // transfer a host to the team to ensure all ABM calls are made + h, err := s.ds.HostByIdentifier(context.Background(), "FAKE-1") + require.NoError(t, err) + s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &addHostsToTeamResponse{}) + // re-set the global state to configure MDM SSO - err := s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil) + err = s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil) require.NoError(t, err) acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -8924,32 +9383,34 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() { ident := uuid.NewString() + mdmDeviceRespond := func(device *mdmtest.TestAppleMDMClient) { + cmd, err := device.Idle() + require.NoError(t, err) + for cmd != nil { + if cmd.Command.RequestType == "InstallProfile" { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + + if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), ident) { + var errChain []mdm.ErrorChain + errChain = append(errChain, mdm.ErrorChain{ErrorCode: -102, ErrorDomain: "CPProfile", USEnglishDescription: "The profile is either missing some required information, or contains information in an invalid format."}) + cmd, err = device.Err(cmd.CommandUUID, errChain) + require.NoError(t, err) + continue + } + } + cmd, err = device.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + } + globalProfiles := [][]byte{ mobileconfigForTest("N1", ident), mobileconfigForTest("N2", "I2"), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) s.awaitTriggerProfileSchedule(t) - - cmd, err := mdmDevice.Idle() - require.NoError(t, err) - for cmd != nil { - if cmd.Command.RequestType == "InstallProfile" { - var fullCmd micromdm.CommandPayload - require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) - - if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), ident) { - var errChain []mdm.ErrorChain - errChain = append(errChain, mdm.ErrorChain{ErrorCode: -102, ErrorDomain: "CPProfile", USEnglishDescription: "The profile is either missing some required information, or contains information in an invalid format."}) - cmd, err = mdmDevice.Err(cmd.CommandUUID, errChain) - require.NoError(t, err) - continue - } - } - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - + mdmDeviceRespond(mdmDevice) require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, host, map[string]*fleet.HostMacOSProfile{ "I2": {Identifier: "I2", DisplayName: "I2", InstallDate: time.Now()}, "I1": {Identifier: "I1", DisplayName: "I1", InstallDate: time.Now()}, @@ -8957,24 +9418,7 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() { // Do another trigger + command fetching cycle, since we retry when a profile fails on install. s.awaitTriggerProfileSchedule(t) - cmd, err = mdmDevice.Idle() - require.NoError(t, err) - for cmd != nil { - if cmd.Command.RequestType == "InstallProfile" { - var fullCmd micromdm.CommandPayload - require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) - - if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), ident) { - var errChain []mdm.ErrorChain - errChain = append(errChain, mdm.ErrorChain{ErrorCode: -102, ErrorDomain: "CPProfile", USEnglishDescription: "The profile is either missing some required information, or contains information in an invalid format."}) - cmd, err = mdmDevice.Err(cmd.CommandUUID, errChain) - require.NoError(t, err) - continue - } - } - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } + mdmDeviceRespond(mdmDevice) require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, host, map[string]*fleet.HostMacOSProfile{ "I1": {Identifier: "I1", DisplayName: "I1", InstallDate: time.Now()}, @@ -9009,14 +9453,47 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() { for _, hm := range *getHostResp.Host.MDM.Profiles { require.NotEqual(t, "N1", hm.Name) } + + // Test case where the profile never makes it to the host at all + host, _ = createHostThenEnrollMDM(s.ds, s.server.URL, t) + ident = uuid.NewString() + + globalProfiles = [][]byte{ + mobileconfigForTest("N3", ident), + } + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) + s.awaitTriggerProfileSchedule(t) + + getHostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.Profiles) + require.Len(t, *getHostResp.Host.MDM.Profiles, 3) + var profUUID string + for _, hm := range *getHostResp.Host.MDM.Profiles { + require.Equal(t, fleet.MDMDeliveryPending, *hm.Status) + if hm.Name == "N3" { + profUUID = hm.ProfileUUID + } + } + + // delete the custom profile + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", profUUID), &deleteMDMAppleConfigProfileRequest{}, http.StatusOK) + s.awaitTriggerProfileSchedule(t) + + getHostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.Profiles) + require.Len(t, *getHostResp.Host.MDM.Profiles, 2) + for _, hm := range *getHostResp.Host.MDM.Profiles { + require.Equal(t, fleet.MDMDeliveryPending, *hm.Status) + } } func (s *integrationMDMTestSuite) TestABMAssetManagement() { t := s.T() ctx := context.Background() - // ensure enable ABM again for other tests - t.Cleanup(s.enableABM) + s.enableABM(t.Name()) // Validate error when server private key not set testSetEmptyPrivateKey = true @@ -9032,21 +9509,21 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { require.Nil(t, abmResp.Err) require.NotEmpty(t, abmResp.PublicKey) + var tokensResp listABMTokensResponse + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok := s.getABMTokenByName(t.Name(), tokensResp.Tokens) + // disable ABM - s.Do("DELETE", "/api/latest/fleet/mdm/apple/abm_token", nil, http.StatusNoContent) - assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetABMCert, - fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, - }) + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/abm_tokens/%d", tok.ID), nil, http.StatusNoContent) + tok, err := s.ds.GetABMTokenByOrgName(ctx, t.Name()) var nfe fleet.NotFoundError require.ErrorAs(t, err, &nfe) - require.Nil(t, assets) + require.Nil(t, tok) - // try to upload a token without a keypair - s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please generate a keypair first.") + // try to upload an invalid token + s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please provide a valid token from Apple Business Manager") - // enable ABM again, creates a new keypair because the previous one was deleted + // enable ABM again var newABMResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &newABMResp) require.Nil(t, newABMResp.Err) @@ -9054,9 +9531,8 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { block, _ := pem.Decode(newABMResp.PublicKey) require.NotNil(t, block) require.Equal(t, "CERTIFICATE", block.Type) - require.NotEqual(t, abmResp.PublicKey, newABMResp.PublicKey) - // as long as the certs are not deleted, we should return the same values to support renewing the token + // we should always return the same values to support renewing the token var renewABMResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &renewABMResp) require.Nil(t, renewABMResp.Err) @@ -9064,10 +9540,10 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { require.Equal(t, renewABMResp.PublicKey, newABMResp.PublicKey) // simulate a renew flow - s.enableABM() + s.enableABM(t.Name()) } -func (s *integrationMDMTestSuite) enableABM() { +func (s *integrationMDMTestSuite) enableABM(orgName string) { t := s.T() var abmResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &abmResp) @@ -9104,6 +9580,16 @@ func (s *integrationMDMTestSuite) enableABM() { encryptedToken, err := pkcs7.Encrypt([]byte(smimeToken), []*x509.Certificate{cert}) require.NoError(t, err) + s.mockDEPResponse(apple_mdm.UnsavedABMTokenOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName))) + } + })) + // upload the encrypted token smimeMessage := fmt.Sprintf( "Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\r\n"+ @@ -9118,12 +9604,31 @@ func (s *integrationMDMTestSuite) enableABM() { assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, }) require.NoError(t, err) - require.Len(t, assets, 3) - require.Equal(t, smimeMessage, string(assets[fleet.MDMAssetABMToken].Value)) + require.Len(t, assets, 2) require.Equal(t, abmResp.PublicKey, assets[fleet.MDMAssetABMCert].Value) + + tok, err := s.ds.GetABMTokenByOrgName(ctx, orgName) + require.NoError(t, err) + require.Equal(t, orgName, tok.OrganizationName) + + // do a dummy call so the nanodep client updates the org name in + // nano_dep_names, and leave the mock set with a dummy response + s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName))) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + depClient := apple_mdm.NewDEPClient(s.depStorage, s.ds, s.logger) + _, err = depClient.AccountDetail(ctx, orgName) + require.NoError(t, err) } func (s *integrationMDMTestSuite) appleCoreCertsSetup() { @@ -9184,7 +9689,7 @@ func (s *integrationMDMTestSuite) appleCoreCertsSetup() { certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, testCert, csr.PublicKey, testKey) require.NoError(t, err) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", certPEM, http.StatusAccepted, "") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/apns_certificate", "certificate", "certificate.pem", certPEM, http.StatusAccepted, "", nil) assets, err = s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert, fleet.MDMAssetCAKey, fleet.MDMAssetAPNSKey, fleet.MDMAssetAPNSCert}) require.NoError(t, err) @@ -9211,7 +9716,7 @@ func (s *integrationMDMTestSuite) uploadABMToken(encryptedToken []byte, expected "Authorization": fmt.Sprintf("Bearer %s", s.token), } - res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/abm_token", b.Bytes(), expectedStatus, headers) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/abm_tokens", b.Bytes(), expectedStatus, headers) if wantErr != "" { errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, wantErr) @@ -9234,7 +9739,8 @@ func (s *integrationMDMTestSuite) TestSilentMigrationGotchas() { require.False(t, *hostResp.Host.MDM.ConnectedToFleet) // simulate that the device is assigned to Fleet in ABM - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -9389,7 +9895,10 @@ func (s *integrationMDMTestSuite) TestAPNsPushCron() { defer func() { s.pushProvider.PushFunc = originalPushMock }() var recordedPushes []*mdm.Push + var mu sync.Mutex s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + mu.Lock() + defer mu.Unlock() recordedPushes = pushes return mockSuccessfulPush(pushes) } @@ -9535,11 +10044,12 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { }) require.NoError(t, err) - // No vpp token set, no association + // No vpp token set, but request is empty so it succeeds (clears VPP apps for the team). s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) // No vpp token set, try association - s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) + // FIXME + // s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) // Valid token orgName := "Fleet Device Management Inc." @@ -9547,7 +10057,12 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { expDate := "2025-06-24T15:50:50+0000" tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + var vppRes uploadVPPTokenResponse + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &vppRes) + + var resPatchVPP patchVPPTokensTeamsResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) + // Remove all vpp associations from team with no members s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) @@ -9576,6 +10091,26 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { // Remove all vpp associations from team with no members s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + // Incorrect type check + incorrectTypes := struct { + Apps []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + } `json:"app_store_apps"` + }{ + Apps: []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + }{ + { + AppStoreID: 1, + }, + }, + } + badTypeReq := s.Do("POST", batchURL, incorrectTypes, http.StatusBadRequest, "team_name", tmGood.Name) + badTypeBody := extractServerErrorText(badTypeReq.Body) + assert.Contains(t, badTypeBody, "must be a string") + // Associating an app we don't own s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: "fake-app"}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) @@ -9610,7 +10145,7 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { batchAssociateAppStoreAppsRequest{ Apps: []fleet.VPPBatchPayload{ {AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}, - {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID}, + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, SelfService: true}, }, }, http.StatusNoContent, "team_name", tmGood.Name, ) @@ -9620,7 +10155,28 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { assert.Contains(t, assoc, fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[0].AdamID, Platform: fleet.MacOSPlatform}) assert.Contains(t, assoc, fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IOSPlatform}) assert.Contains(t, assoc, fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IPadOSPlatform}) - assert.Contains(t, assoc, fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.MacOSPlatform}) + // Only macOS version should be self-service + assert.Equal(t, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.MacOSPlatform}, SelfService: true}, + assoc[fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.MacOSPlatform}]) + + // Reverse self-service associations + // Associating two apps we own + s.Do("POST", + batchURL, + batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID, SelfService: true}, + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, SelfService: false}, + }, + }, http.StatusNoContent, "team_name", tmGood.Name, + ) + assoc, err = s.ds.GetAssignedVPPApps(ctx, &tmGood.ID) + require.NoError(t, err) + require.Len(t, assoc, 4) + assert.Equal(t, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[0].AdamID, Platform: fleet.MacOSPlatform}, SelfService: true}, assoc[fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[0].AdamID, Platform: fleet.MacOSPlatform}]) + assert.Equal(t, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IOSPlatform}}, assoc[fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IOSPlatform}]) + assert.Equal(t, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IPadOSPlatform}}, assoc[fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.IPadOSPlatform}]) + assert.Equal(t, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.MacOSPlatform}}, assoc[fleet.VPPAppID{AdamID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, Platform: fleet.MacOSPlatform}]) // Associate an app with a team with no team members s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}}}, http.StatusNoContent, "team_name", tmEmpty.Name) @@ -9667,7 +10223,6 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() { var listCmdResp listMDMAppleCommandsResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp) require.Empty(t, listCmdResp.Results) - } func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { @@ -9689,6 +10244,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { // Enroll host host, mdmClient := s.createAppleMobileHostThenEnrollMDM("ios") + require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), host.ID, false, true, "https://foo.com", true, "", "")) // Refetch host _ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK) @@ -9700,6 +10256,17 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { assert.Equal(t, host.ID, hostResp.Host.ID) assert.True(t, hostResp.Host.RefetchRequested) + commands, err := s.ds.GetHostMDMCommands(context.Background(), host.ID) + require.NoError(t, err) + require.Len(t, commands, commandsSent) + assert.ElementsMatch(t, []fleet.HostMDMCommand{ + {HostID: host.ID, CommandType: fleet.RefetchAppsCommandUUIDPrefix}, + {HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix}, + }, commands) + + // Since refetch is already queued up, doing another refetch is a no-op and will not add more MDM commands + _ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK) + // Check the MDM commands and send response cmd, err := mdmClient.Idle() require.NoError(t, err) @@ -9724,6 +10291,10 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE") require.NoError(t, err) + commands, err = s.ds.GetHostMDMCommands(context.Background(), host.ID) + require.NoError(t, err) + require.Empty(t, commands) + hostResp = getHostResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) assert.Equal(t, host.ID, hostResp.Host.ID) @@ -9857,61 +10428,110 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { assert.Equal(t, deviceNameRenamed, hostResp.Host.ComputerName) assert.Empty(t, hostResp.Host.Software) + // Mark host as unenrolled and refetch. + require.NoError(t, s.ds.UpdateMDMData(ctx, host.ID, false)) + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + require.NoError(t, err) + require.NotNil(t, hostResp.Host.MDM.EnrollmentStatus) + assert.Equal(t, "Pending", *hostResp.Host.MDM.EnrollmentStatus) + + // Set iOS detail_updated_at as 2 hours in the past. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE hosts SET detail_updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE id = ?`, host.ID) + return err + }) + trigger := triggerRequest{ + Name: string(fleet.CronAppleMDMIPhoneIPadRefetcher), + } + _ = s.Do("POST", "/api/latest/fleet/trigger", trigger, http.StatusOK) + commandsSent += commandsSentPerRefetch + + // Wait until MDM commands are set up + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + commands, err = s.ds.GetHostMDMCommands(context.Background(), host.ID) + require.NoError(t, err) + if len(commands) == commandsSentPerRefetch { + done <- struct{}{} + return + } + } + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("Timeout: MDM commands not queued up") + } + + // Check the MDM commands and send response + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType) + cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{}) + require.NoError(t, err) + require.Equal(t, "DeviceInformation", cmd.Command.RequestType) + cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE") + require.NoError(t, err) + require.Nil(t, cmd) + + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + assert.Equal(t, host.ID, hostResp.Host.ID) + assert.False(t, hostResp.Host.RefetchRequested) + require.NotNil(t, hostResp.Host.MDM.EnrollmentStatus) + assert.Equal(t, "On (automatic)", *hostResp.Host.MDM.EnrollmentStatus) + // list commands should return all the commands we sent var listCmdResp listMDMAppleCommandsResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp) require.Len(t, listCmdResp.Results, commandsSent) - } func (s *integrationMDMTestSuite) TestVPPApps() { t := s.T() // Invalid token t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?invalidToken") - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.") + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.", nil) // Simulate a server error from the Apple API t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)", nil) // Valid token orgName := "Fleet Device Management Inc." location := "Fleet Location One" token := "mycooltoken" - expDate := "2025-06-24T15:50:50+0000" + expTime := time.Now().Add(200 * time.Hour).UTC().Round(time.Second) + expDate := expTime.Format(fleet.VPPTimeFormat) tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + var validToken uploadVPPTokenResponse + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "", &validToken) s.lastActivityMatches(fleet.ActivityEnabledVPP{}.ActivityName(), "", 0) // Get the token - var resp getMDMAppleVPPTokenResponse - s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp) + var resp getVPPTokensResponse + s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp) require.NoError(t, resp.Err) - require.Equal(t, orgName, resp.OrgName) - require.Equal(t, location, resp.Location) - require.Equal(t, expDate, resp.RenewDate) - - // Simulate renewal flow - orgName = "Fleet Device Management Inc. New Org Name" - token = "myothercooltoken" - expDate = "2026-06-24T15:50:50+0000" - tokenJSON = fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") - - resp = getMDMAppleVPPTokenResponse{} - s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusOK, &resp) - require.NoError(t, resp.Err) - require.Equal(t, orgName, resp.OrgName) - require.Equal(t, location, resp.Location) - require.Equal(t, expDate, resp.RenewDate) + require.Len(t, resp.Tokens, 1) + require.Equal(t, orgName, resp.Tokens[0].OrgName) + require.Equal(t, location, resp.Tokens[0].Location) + require.Equal(t, expTime, resp.Tokens[0].RenewDate) // Create a team var newTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) team := newTeamResp.Team + var resPatchVPP patchVPPTokensTeamsResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", resp.Tokens[0].ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) + // Get list of VPP apps from "Apple" // We're passing team 1 here, but we haven't added any app store apps to that team, so we get // back all available apps in our VPP location. @@ -9919,9 +10539,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.DoJSON("GET", "/api/latest/fleet/software/app_store_apps", &getAppStoreAppsRequest{}, http.StatusOK, &appResp, "team_id", strconv.Itoa(int(team.ID))) require.NoError(t, appResp.Err) macOSApp := fleet.VPPApp{ - VPPAppID: fleet.VPPAppID{ - AdamID: "1", - Platform: fleet.MacOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "1", + Platform: fleet.MacOSPlatform, + }, }, Name: "App 1", BundleIdentifier: "a-1", @@ -9929,9 +10551,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { LatestVersion: "1.0.0", } iPadOSApp := fleet.VPPApp{ - VPPAppID: fleet.VPPAppID{ - AdamID: "2", - Platform: fleet.IPadOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.IPadOSPlatform, + }, }, Name: "App 2", BundleIdentifier: "b-2", @@ -9939,9 +10563,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { LatestVersion: "2.0.0", } iOSApp := fleet.VPPApp{ - VPPAppID: fleet.VPPAppID{ - AdamID: "2", - Platform: fleet.IOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.IOSPlatform, + }, }, Name: "App 2", BundleIdentifier: "b-2", @@ -9953,9 +10579,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { &iPadOSApp, &iOSApp, { - VPPAppID: fleet.VPPAppID{ - AdamID: "2", - Platform: fleet.MacOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.MacOSPlatform, + }, }, Name: "App 2", BundleIdentifier: "b-2", @@ -9963,9 +10591,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { LatestVersion: "2.0.0", }, { - VPPAppID: fleet.VPPAppID{ - AdamID: "3", - Platform: fleet.IPadOSPlatform, + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "3", + Platform: fleet.IPadOSPlatform, + }, }, Name: "App 3", BundleIdentifier: "c-3", @@ -9982,9 +10612,9 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // Add an app store app to non-existent team s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: ptr.Uint(9999), AppStoreID: addedApp.AdamID}, http.StatusNotFound, &addAppResp) - s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID}, http.StatusOK, &addAppResp) + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, SelfService: true}, http.StatusOK, &addAppResp) s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), - fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s"}`, team.Name, + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID, addedApp.Platform), 0) // Now we should be filtering out the app we added to team 1 @@ -9997,8 +10627,15 @@ func (s *integrationMDMTestSuite) TestVPPApps() { var listSw listSoftwareTitlesResponse s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "available_for_install", "true") require.Len(t, listSw.SoftwareTitles, 1) + require.True(t, *listSw.SoftwareTitles[0].AppStoreApp.SelfService) macOSTitleID := listSw.SoftwareTitles[0].ID + // listing with the self-service filter also returns it + listSw = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), "self_service", "true") + require.Len(t, listSw.SoftwareTitles, 1) + require.Equal(t, macOSTitleID, listSw.SoftwareTitles[0].ID) + // delete the app store app for team 1 s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", macOSTitleID), nil, http.StatusNoContent, "team_id", fmt.Sprint(team.ID)) @@ -10019,11 +10656,15 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // Insert/deletion flow for iPadOS app addedApp = expectedApps[1] addAppResp = addAppStoreAppResponse{} + // No self-service for iPadOS + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, Platform: addedApp.Platform, SelfService: true}, + http.StatusBadRequest, &addAppResp) s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: addedApp.AdamID, Platform: addedApp.Platform}, http.StatusOK, &addAppResp) s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(), - fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s"}`, team.Name, + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": false}`, team.Name, addedApp.Name, addedApp.AdamID, team.ID, addedApp.Platform), 0) // Now we should be filtering out the app we added to team 1 @@ -10041,6 +10682,12 @@ func (s *integrationMDMTestSuite) TestVPPApps() { macOSTitleID = listSw.SoftwareTitles[0].ID assert.Equal(t, "ipados_apps", listSw.SoftwareTitles[0].Source) + // filtering by self-service returns nothing + listSw = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), + "self_service", "true") + require.Len(t, listSw.SoftwareTitles, 0) + // delete the app store app for team 1 s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", macOSTitleID), nil, http.StatusNoContent, "team_id", @@ -10066,6 +10713,11 @@ func (s *integrationMDMTestSuite) TestVPPApps() { orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) mdmHost, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) setOrbitEnrollment(t, mdmHost, s.ds) + selfServiceHost, selfServiceDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + setOrbitEnrollment(t, selfServiceHost, s.ds) + selfServiceToken := "selfservicetoken" + updateDeviceTokenForHost(t, s.ds, selfServiceHost.ID, selfServiceToken) + s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, selfServiceDevice.SerialNumber) iOSHost, iOSMdmClient := s.createAppleMobileHostThenEnrollMDM("ios") iPadOSHost, iPadOSMdmClient := s.createAppleMobileHostThenEnrollMDM("ipados") @@ -10073,19 +10725,35 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.appleVPPConfigSrvConfig.SerialNumbers = append(s.appleVPPConfigSrvConfig.SerialNumbers, mdmHost.HardwareSerial, iOSHost.HardwareSerial, iPadOSHost.HardwareSerial) s.Do("POST", "/api/latest/fleet/hosts/transfer", - &addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, orbitHost.ID, iOSHost.ID, iPadOSHost.ID}, TeamID: &team.ID}, http.StatusOK) + &addHostsToTeamRequest{HostIDs: []uint{mdmHost.ID, orbitHost.ID, iOSHost.ID, iPadOSHost.ID, selfServiceHost.ID}, TeamID: &team.ID}, http.StatusOK) // Add all apps to the team addedApp = expectedApps[0] errApp := expectedApps[3] - for _, app := range expectedApps { + appSelfService := expectedApps[0] + // Add app 1 as self-service + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: appSelfService.AdamID, Platform: appSelfService.Platform, SelfService: true}, + http.StatusOK, &addAppResp) + s.lastActivityMatches( + fleet.ActivityAddedAppStoreApp{}.ActivityName(), + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": true}`, team.Name, + appSelfService.Name, appSelfService.AdamID, team.ID, appSelfService.Platform), + 0, + ) + listSw = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSw, "team_id", fmt.Sprint(team.ID), + "available_for_install", "true") + // Add remaining as non-self-service + for _, app := range expectedApps[1:] { addAppResp = addAppStoreAppResponse{} s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.Platform}, http.StatusOK, &addAppResp) s.lastActivityMatches( fleet.ActivityAddedAppStoreApp{}.ActivityName(), - fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s"}`, team.Name, + fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "app_store_id": "%s", "team_id": %d, "platform": "%s", "self_service": false}`, team.Name, app.Name, app.AdamID, team.ID, app.Platform), 0, ) @@ -10117,27 +10785,61 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // attempt to install a VPP app on the non-MDM enrolled host installResp := installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, macOSTitleID), &installSoftwareRequest{}, + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", orbitHost.ID, macOSTitleID), &installSoftwareRequest{}, http.StatusBadRequest, &installResp) + // Disable all teams token + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", validToken.Token.ID), patchVPPTokensTeamsRequest{}, http.StatusOK, &resPatchVPP) + // Spoof an expired VPP token and attempt to install VPP app - tokenJSON = fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, "2020-06-24T15:50:50+0000", token, orgName) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + tokenJSONBad := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, "2099-06-24T15:50:50+0000", "badtoken", "Evil Fleet") + s.appleVPPConfigSrvConfig.Location = "Spooky Haunted House" + var vppRes uploadVPPTokenResponse + s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSONBad))), http.StatusAccepted, "", &vppRes) - r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) - require.Contains(t, extractServerErrorText(r.Body), "VPP token expired") + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, 99}}, http.StatusUnprocessableEntity, &resPatchVPP) - // Put a valid token back in - tokenJSON = fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, "2050-06-24T15:50:50+0000", token, orgName) - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) + + // mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + // _, err := q.ExecContext(context.Background(), "UPDATE vpp_tokens SET renew_at = ? WHERE organization_name = ?", time.Now().Add(-1*time.Hour), "badtoken") + // return err + // }) + + // r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) + // require.Contains(t, extractServerErrorText(r.Body), "VPP token expired") + + // Disable the token + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{}, http.StatusOK, &resPatchVPP) + + // Enable all teams token + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", validToken.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) // Attempt to install non-existent app - r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, 99999), &installSoftwareRequest{}, http.StatusBadRequest) + r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, 99999), &installSoftwareRequest{}, + http.StatusBadRequest) require.Contains(t, extractServerErrorText(r.Body), "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.") + // Add app 1 as self-service + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: errApp.AdamID, Platform: errApp.Platform, SelfService: true}, + http.StatusOK, &addAppResp) + + // Add remaining apps without self-service + for _, app := range expectedApps { + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.Platform, SelfService: app.AdamID == macOSApp.AdamID}, + http.StatusOK, &addAppResp) + } + // Trigger install to the host installResp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, errTitleID), &installSoftwareRequest{}, + http.StatusAccepted, &installResp) + + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d", vppRes.Token.ID), &deleteVPPTokenRequest{}, http.StatusNoContent) // Check if the host is listed as pending var listResp listHostsResponse @@ -10174,20 +10876,20 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false}`, mdmHost.ID, mdmHost.DisplayName(), errApp.Name, errApp.AdamID, failedCmdUUID, - fleet.SoftwareInstallerFailed, + fleet.SoftwareInstallFailed, ), 0, ) // Trigger install to the host installResp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, macOSTitleID), &installSoftwareRequest{}, + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp) countResp = countHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", @@ -10221,13 +10923,13 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, addedApp.AdamID, cmdUUID, - fleet.SoftwareInstallerInstalled, + fleet.SoftwareInstalled, ), 0, ) @@ -10245,13 +10947,13 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(addedApp.IconURL)) require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion) - require.NotNil(t, *got1.Status) - require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled) + require.NotNil(t, got1.Status) + require.Equal(t, *got1.Status, fleet.SoftwareInstalled) require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID) require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt) require.Equal(t, got2.Name, "App 2") - require.NotNil(t, *got2.Status) - require.Equal(t, *got2.Status, fleet.SoftwareInstallerFailed) + require.NotNil(t, got2.Status) + require.Equal(t, *got2.Status, fleet.SoftwareInstallFailed) require.NotNil(t, got2.AppStoreApp) require.Equal(t, got2.AppStoreApp.AppStoreID, errApp.AdamID) require.Equal(t, got2.AppStoreApp.IconURL, ptr.String(errApp.IconURL)) @@ -10272,11 +10974,65 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(addedApp.IconURL)) require.Empty(t, got1.AppStoreApp.Name) require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion) - require.NotNil(t, *got1.Status) - require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled) + require.NotNil(t, got1.Status) + require.Equal(t, *got1.Status, fleet.SoftwareInstalled) require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID) require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt) + // Filter the self-service apps for that host + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, appSelfService.Name, got1.Name) + + // Return installed app with software detail query + distributedReq := submitDistributedQueryResultsRequestShim{ + NodeKey: *mdmHost.NodeKey, + Results: map[string]json.RawMessage{ + hostDetailQueryPrefix + "software_macos": json.RawMessage(fmt.Sprintf( + `[{"name": "%s", "version": "%s", "type": "Application (macOS)", + "bundle_identifier": "%s", "source": "apps", "last_opened_at": "", + "installed_path": "/Applications/a.app"}]`, addedApp.Name, addedApp.LatestVersion, addedApp.BundleIdentifier)), + }, + Statuses: map[string]interface{}{ + hostDistributedQueryPrefix + "software_macos": 0, + }, + Messages: map[string]string{}, + Stats: map[string]*fleet.Stats{}, + } + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp) + + // Remove the installed app by not returning it + distributedReq = submitDistributedQueryResultsRequestShim{ + NodeKey: *mdmHost.NodeKey, + Results: map[string]json.RawMessage{ + hostDetailQueryPrefix + "software_macos": json.RawMessage(`[]`), + }, + Statuses: map[string]interface{}{ + hostDistributedQueryPrefix + "software_macos": 0, + }, + Messages: map[string]string{}, + Stats: map[string]*fleet.Stats{}, + } + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp) + + // Check list host software + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", mdmHost.ID), nil, http.StatusOK, &getHostSw) + gotSW = getHostSw.Software + require.Len(t, gotSW, 2) // App 1 and App 2 + got1, got2 = gotSW[0], gotSW[1] + require.Equal(t, got1.Name, "App 1") + require.NotNil(t, got1.AppStoreApp) + require.Equal(t, got1.AppStoreApp.AppStoreID, addedApp.AdamID) + require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(addedApp.IconURL)) + require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages + require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion) + assert.Nil(t, got1.Status) + assert.Nil(t, got1.AppStoreApp.LastInstall) + // Install on iOS and iPadOS devices installs := map[string]struct { installHost *fleet.Host @@ -10284,28 +11040,49 @@ func (s *integrationMDMTestSuite) TestVPPApps() { mdmClient *mdmtest.TestAppleMDMClient app fleet.VPPApp extraAvailable int + hostCount int + deviceToken string }{ - "iOS app install": {installHost: iOSHost, titleID: iOSTitleID, mdmClient: iOSMdmClient, app: iOSApp}, - "iPadOS app install": {installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp, - extraAvailable: 1}, + "iOS app install": {installHost: iOSHost, titleID: iOSTitleID, mdmClient: iOSMdmClient, app: iOSApp, hostCount: 1}, + "iPadOS app install": { + installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp, + extraAvailable: 1, hostCount: 1, + }, + "macOS app install": { + installHost: selfServiceHost, titleID: macOSTitleID, mdmClient: selfServiceDevice, app: macOSApp, + hostCount: 2, deviceToken: selfServiceToken, + }, } for name, install := range installs { t.Run(name, func(t *testing.T) { - installHost := install.installHost titleID := install.titleID mdmClient := install.mdmClient app := install.app - installResp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", installHost.ID, titleID), - &installSoftwareRequest{}, http.StatusAccepted, &installResp) + // Self-service install + if install.deviceToken != "" { + var ssInstallResp submitSelfServiceSoftwareInstallResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", install.deviceToken, install.titleID), + &fleetSelfServiceSoftwareInstallRequest{}, http.StatusAccepted, &ssInstallResp) + } else { + installResp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", installHost.ID, titleID), + &installSoftwareRequest{}, http.StatusAccepted, &installResp) + } countResp = countHostsResponse{} s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) require.Equal(t, 1, countResp.Count) + // send an idle request to grab the command uuid + cmd, err = mdmClient.Idle() + require.NoError(t, err) + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + cmdUUID = cmd.CommandUUID + // Get pending activity var hostActivitiesResp listHostUpcomingActivitiesResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", installHost.ID), @@ -10320,40 +11097,45 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", activitiesToString(hostActivitiesResp.Activities)) assert.Equal(t, hostActivitiesResp.Activities[0].Type, fleet.ActivityInstalledAppStoreApp{}.ActivityName()) assert.EqualValues(t, 1, hostActivitiesResp.Count) - - // Simulate successful installation on the host - cmd, err = mdmClient.Idle() - require.NoError(t, err) - for cmd != nil { - var fullCmd micromdm.CommandPayload - switch cmd.Command.RequestType { - case "InstallApplication": - require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) - cmdUUID = cmd.CommandUUID - cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - } - - listResp = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", - strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) - assert.Len(t, listResp.Hosts, 1) - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", - strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) - assert.Equal(t, 1, countResp.Count) - - s.lastActivityMatches( - fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + assert.JSONEq( + t, fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v}`, installHost.ID, installHost.DisplayName(), app.Name, app.AdamID, cmdUUID, - fleet.SoftwareInstallerInstalled, + fleet.SoftwareInstallPending, + install.deviceToken != "", + ), + string(*hostActivitiesResp.Activities[0].Details), + ) + + // Simulate successful installation on the host + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + listResp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", + strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) + assert.Len(t, listResp.Hosts, install.hostCount) + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", + strconv.Itoa(int(team.ID)), "software_title_id", strconv.Itoa(int(titleID))) + assert.Equal(t, install.hostCount, countResp.Count) + + s.lastActivityMatches( + fleet.ActivityInstalledAppStoreApp{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v}`, + installHost.ID, + installHost.DisplayName(), + app.Name, + app.AdamID, + cmdUUID, + fleet.SoftwareInstalled, + install.deviceToken != "", ), 0, ) @@ -10361,7 +11143,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { // Check list host software getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", installHost.ID), nil, http.StatusOK, &getHostSw) - require.Len(t, getHostSw.Software, 1+install.extraAvailable) + require.Len(t, getHostSw.Software, install.hostCount+install.extraAvailable) var foundInstalledApp bool for index := range getHostSw.Software { got1 = getHostSw.Software[index] @@ -10372,7 +11154,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(app.IconURL)) require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages require.Equal(t, got1.AppStoreApp.Version, app.LatestVersion) - require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled) + require.Equal(t, *got1.Status, fleet.SoftwareInstalled) require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID) require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt) foundInstalledApp = true @@ -10382,8 +11164,231 @@ func (s *integrationMDMTestSuite) TestVPPApps() { }) } + // Attempt (and fail) to self-service install iPad and iOS titles + var ssInstallResp submitSelfServiceSoftwareInstallResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", selfServiceToken, iPadOSTitleID), &fleetSelfServiceSoftwareInstallRequest{}, + http.StatusBadRequest, &ssInstallResp) + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/device/%s/software/install/%d", selfServiceToken, iOSTitleID), &fleetSelfServiceSoftwareInstallRequest{}, + http.StatusBadRequest, &ssInstallResp) + // Delete VPP token and check that it's not appearing anymore - s.Do("DELETE", "/api/latest/fleet/mdm/apple/vpp_token", &deleteMDMAppleVPPTokenRequest{}, http.StatusNoContent) - s.DoJSON("GET", "/api/latest/fleet/vpp", &getMDMAppleVPPTokenRequest{}, http.StatusNotFound, &resp) - s.lastActivityMatches(fleet.ActivityDisabledVPP{}.ActivityName(), "", 0) + + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d", validToken.Token.ID), &deleteVPPTokenResponse{}, http.StatusNoContent) + s.DoJSON("GET", "/api/latest/fleet/vpp_tokens", &getVPPTokensRequest{}, http.StatusOK, &resp) +} + +func (s *integrationMDMTestSuite) TestEnrollmentProfilesWithSpecialChars() { + t := s.T() + ctx := context.Background() + + initialConfig, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + initialSecrets, err := s.ds.GetEnrollSecrets(ctx, nil) + require.NoError(t, err) + + nameWithInvalidChars := "Fleet & Device <3 Management" + /* #nosec G101 -- this is a made up value for tests */ + enrollSecretWithInvalidChars := "1<2>3&4&/" + + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ + "org_info": { + "org_name": %q + } + }`, nameWithInvalidChars)), http.StatusOK, &acResp) + enrollSecretResp := applyEnrollSecretSpecResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: enrollSecretWithInvalidChars}}, + }, + }, http.StatusOK, &enrollSecretResp) + t.Cleanup(func() { + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ + "org_info": { + "org_name": %q + } + }`, initialConfig.OrgInfo.OrgName)), http.StatusOK, &acResp) + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: initialSecrets, + }, + }, http.StatusOK, &enrollSecretResp) + }) + + // manual enrollment from My Device + token := "token_test_manual_enroll" + createHostAndDeviceToken(t, s.ds, token) + s.downloadAndVerifyOTAEnrollmentProfile("/api/latest/fleet/device/" + token + "/mdm/apple/manual_enrollment_profile") + + // automatic enrollment by token + rawMsg := json.RawMessage(`{"allow_pairing": true}`) + _, err = s.ds.NewMDMAppleEnrollmentProfile(ctx, fleet.MDMAppleEnrollmentProfilePayload{ + Type: "automatic", + DEPProfile: &rawMsg, + Token: "abcd", + }) + require.NoError(t, err) + s.downloadAndVerifyEnrollmentProfile(apple_mdm.EnrollPath + "?token=abcd") + + // unsigned manual enrollment profile for IT admins + s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual") + + // ensure the fleetd profile sends a good enroll secret too + s.awaitTriggerProfileSchedule(t) + prof := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, true) + + type fleetdPlist struct { + PayloadContent []struct { + EnrollSecret string `plist:"EnrollSecret"` + } `plist:"PayloadContent"` + } + + // Parse the plist data + var parsedData fleetdPlist + err = plist.NewDecoder(bytes.NewReader(prof.Mobileconfig)).Decode(&parsedData) + require.NoError(t, err) + require.Equal(t, enrollSecretWithInvalidChars, parsedData.PayloadContent[0].EnrollSecret) +} + +func (s *integrationMDMTestSuite) TestOTAEnrollment() { + t := s.T() + + // create a global enroll secret + globalSecret := "global_secret" + var applyResp applyEnrollSecretSpecResponse + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}}, + }, + }, http.StatusOK, &applyResp) + + reqBody := []byte(` + + + + PRODUCT + + SERIAL + foo + UDID + + VERSION + + +`) + + // request with no enroll secret + httpResp := s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment", reqBody, http.StatusForbidden) + errMsg := extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.") + require.NoError(t, httpResp.Body.Close()) + + // request with no body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", nil, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + // request with unsigned body + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", reqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "invalid request body") + require.NoError(t, httpResp.Body.Close()) + + cert, key, err := apple_mdm.NewSCEPCACertKey() + require.NoError(t, err) + signedData, err := pkcs7.NewSignedData(reqBody) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err := signedData.Finish() + require.NoError(t, err) + + // request with invalid apple signature + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.") + require.NoError(t, httpResp.Body.Close()) + + // request with invalid device signature + os.Setenv("FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY", "1") + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusForbidden) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "Couldn't install the profile. Invalid enroll secret. Please contact your IT admin.") + require.NoError(t, httpResp.Body.Close()) + + // request without serial number + signedData, err = pkcs7.NewSignedData([]byte(` + + + + SERIAL + + +`)) + require.NoError(t, err) + require.NoError(t, signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})) + signedReqBody, err = signedData.Finish() + require.NoError(t, err) + httpResp = s.DoRawNoAuth("POST", "/api/latest/fleet/ota_enrollment?enroll_secret=foo", signedReqBody, http.StatusBadRequest) + errMsg = extractServerErrorText(httpResp.Body) + require.Contains(t, errMsg, "SERIAL is required") + require.NoError(t, httpResp.Body.Close()) + + checkInstallFleetdCommandSent := func(mdmDevice *mdmtest.TestAppleMDMClient, wantCommand bool) { + foundInstallFleetdCommand := false + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil { + foundInstallFleetdCommand = true + require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType) + require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.Equal(t, wantCommand, foundInstallFleetdCommand) + } + + hwModel := "MacBookPro16,1" + mdmDevice := mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + globalSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, true) + + var hostByIdentifierResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "darwin", hostByIdentifierResp.Host.Platform) + require.Nil(t, hostByIdentifierResp.Host.TeamID) + + // create a team with a different enroll secret + var specResp applyTeamSpecsResponse + teamSecret := "team_secret" + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{Name: "newteam", Secrets: &[]fleet.EnrollSecret{{Secret: teamSecret}}}}} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &specResp) + + hwModel = "iPad13,16" + mdmDevice = mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + teamSecret, + hwModel, + ) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, false) + + hostByIdentifierResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform) + require.NotNil(t, hostByIdentifierResp.Host.TeamID) + require.Equal(t, specResp.TeamIDsByName["newteam"], *hostByIdentifierResp.Host.TeamID) } diff --git a/server/service/labels.go b/server/service/labels.go index c79e8fadb9..2922ec51d8 100644 --- a/server/service/labels.go +++ b/server/service/labels.go @@ -165,6 +165,7 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi if label.LabelType == fleet.LabelTypeBuiltIn { return nil, nil, fleet.NewInvalidArgumentError("label_type", fmt.Sprintf("cannot modify built-in label '%s'", label.Name)) } + originalLabelName := label.Name if payload.Name != nil { // Check if the new name is a reserved label name for name := range fleet.ReservedLabelNames() { @@ -188,7 +189,7 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi // hostnames). if label.LabelMembershipType == fleet.LabelMembershipTypeManual && payload.Hosts != nil { spec := fleet.LabelSpec{ - Name: label.Name, + Name: originalLabelName, Description: label.Description, Query: label.Query, Platform: label.Platform, @@ -200,11 +201,16 @@ func (svc *Service) ModifyLabel(ctx context.Context, id uint, payload fleet.Modi return nil, nil, err } spec.Hosts = hostnames + // Note: ApplyLabelSpecs cannot update label name since it uses the name as a key. + // So, we must handle it later. if err := svc.ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{&spec}); err != nil { return nil, nil, err } - - // must reload it to get the host counts information + // If the label name has changed, we must update it. + if originalLabelName != label.Name { + return svc.ds.SaveLabel(ctx, label, filter) + } + // Otherwise, simply reload label to get the host counts information return svc.ds.Label(ctx, id, filter) } return svc.ds.SaveLabel(ctx, label, filter) diff --git a/server/service/mdm.go b/server/service/mdm.go index bd80e45562..7a06c015cd 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -6,7 +6,6 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" - "encoding/base64" "encoding/json" "encoding/pem" "errors" @@ -31,7 +30,6 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" - "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/assets" nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" @@ -568,13 +566,14 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte var apnsErr *apple_mdm.APNSDeliveryError var mysqlErr *mysql.MySQLError if errors.As(err, &apnsErr) { - if len(apnsErr.FailedUUIDs) < len(deviceIDs) { + failedUUIDs := apnsErr.FailedUUIDs() + if len(failedUUIDs) < len(deviceIDs) { // some hosts properly received the command, so return success, with the list // of failed uuids. return &fleet.CommandEnqueueResult{ CommandUUID: cmd.CommandUUID, RequestType: cmd.Command.RequestType, - FailedUUIDs: apnsErr.FailedUUIDs, + FailedUUIDs: failedUUIDs, }, nil } // push failed for all hosts @@ -1168,7 +1167,7 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1413,7 +1412,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1601,7 +1600,8 @@ func (svc *Service) BatchSetMDMProfiles( return nil } - if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { + var profUpdates fleet.MDMProfilesUpdates + if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { return ctxerr.Wrap(ctx, err, "setting config profiles") } @@ -1610,7 +1610,8 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range windowsProfiles { winProfUUIDs = append(winProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil); err != nil { + winUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } @@ -1619,33 +1620,42 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range appleProfiles { appleProfUUIDs = append(appleProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil); err != nil { + appleUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } + updates := fleet.MDMProfilesUpdates{ + AppleConfigProfile: profUpdates.AppleConfigProfile || winUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile, + WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile || appleUpdates.WindowsConfigProfile, + AppleDeclaration: profUpdates.AppleDeclaration || winUpdates.AppleDeclaration || appleUpdates.AppleDeclaration, + } - // TODO(roberto): should we generate activities only of any profiles were - // changed? this is the existing behavior for macOS profiles so I'm - // leaving it as-is for now. - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + if updates.AppleConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + if updates.WindowsConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + if updates.AppleDeclaration { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + } } return nil @@ -2341,6 +2351,16 @@ func (svc *Service) GetMDMAppleCSR(ctx context.Context) ([]byte, error) { if err != nil { var fwe apple_mdm.FleetWebsiteError if errors.As(err, &fwe) { + // From svc.RequestMDMAppleCSR: fleetdm.com returns a bad request here if the email is invalid. + if fwe.Status >= 400 && fwe.Status <= 499 { + return nil, ctxerr.Wrap( + ctx, + fleet.NewInvalidArgumentError( + "email_address", + fmt.Sprintf("this email address is not valid: %v", err), + ), + ) + } return nil, ctxerr.Wrap( ctx, fleet.NewUserMessageError( @@ -2532,216 +2552,3 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { return svc.ds.SaveAppConfig(ctx, appCfg) } - -//////////////////////////////////////////////////////////////////////////////// -// POST /mdm/apple/vpp_token -//////////////////////////////////////////////////////////////////////////////// - -type uploadMDMAppleVPPTokenRequest struct { - File *multipart.FileHeader -} - -func (uploadMDMAppleVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { - decoded := uploadMDMAppleVPPTokenRequest{} - - err := r.ParseMultipartForm(512 * units.MiB) - if err != nil { - return nil, &fleet.BadRequestError{ - Message: "failed to parse multipart form", - InternalErr: err, - } - } - - if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { - return nil, &fleet.BadRequestError{ - Message: "token multipart field is required", - InternalErr: err, - } - } - - decoded.File = r.MultipartForm.File["token"][0] - - return &decoded, nil -} - -type uploadMDMAppleVPPTokenResponse struct { - Err error `json:"error,omitempty"` -} - -func (r uploadMDMAppleVPPTokenResponse) Status() int { return http.StatusAccepted } - -func (r uploadMDMAppleVPPTokenResponse) error() error { - return r.Err -} - -func uploadMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - req := request.(*uploadMDMAppleVPPTokenRequest) - file, err := req.File.Open() - if err != nil { - return uploadMDMAppleAPNSCertResponse{Err: err}, nil - } - defer file.Close() - - if err := svc.UploadMDMAppleVPPToken(ctx, file); err != nil { - return &uploadMDMAppleVPPTokenResponse{Err: err}, nil - } - - return &uploadMDMAppleVPPTokenResponse{}, nil -} - -func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSeeker) error { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return err - } - - privateKey := svc.config.Server.PrivateKey - if testSetEmptyPrivateKey { - privateKey = "" - } - - if len(privateKey) == 0 { - return ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - if token == nil { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - - tokenBytes, err := io.ReadAll(token) - if err != nil { - return ctxerr.Wrap(ctx, err, "reading VPP token") - } - - locName, err := vpp.GetConfig(string(tokenBytes)) - if err != nil { - var vppErr *vpp.ErrorResponse - if errors.As(err, &vppErr) { - // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes - if vppErr.ErrorNumber == 9622 { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - } - return ctxerr.Wrap(ctx, err, "validating VPP token with Apple") - } - - data := fleet.VPPTokenData{ - Token: string(tokenBytes), - Location: locName, - } - - dataBytes, err := json.Marshal(data) - if err != nil { - return ctxerr.Wrap(ctx, err, "creating VPP data object for storage") - } - - err = svc.ds.ReplaceMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ - {Name: fleet.MDMAssetVPPToken, Value: dataBytes}, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "writing VPP token to db") - } - - act := fleet.ActivityEnabledVPP{} - if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for upload VPP token") - } - - return nil -} - -//////////////////////////////////////////////////////////////////////////////// -// GET /vpp -//////////////////////////////////////////////////////////////////////////////// - -type getMDMAppleVPPTokenRequest struct{} - -type getMDMAppleVPPTokenResponse struct { - *fleet.VPPTokenInfo - Err error `json:"error,omitempty"` -} - -func (r getMDMAppleVPPTokenResponse) error() error { - return r.Err -} - -func getMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - vpp, err := svc.GetMDMAppleVPPToken(ctx) - if err != nil { - return &getMDMAppleVPPTokenResponse{Err: err}, nil - } - - return &getMDMAppleVPPTokenResponse{VPPTokenInfo: vpp}, nil -} - -func (svc *Service) GetMDMAppleVPPToken(ctx context.Context) (*fleet.VPPTokenInfo, error) { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil { - return nil, err - } - - assetMap, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get mdm config assets by name VPP token") - } - - var tokenData fleet.VPPTokenData - if err := json.Unmarshal(assetMap[fleet.MDMAssetVPPToken].Value, &tokenData); err != nil { - return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token data") - } - - var rawToken fleet.VPPTokenRaw - decodedBytes, err := base64.StdEncoding.DecodeString(tokenData.Token) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "decoding VPP token") - } - - if err := json.Unmarshal(decodedBytes, &rawToken); err != nil { - return nil, ctxerr.Wrap(ctx, err, "unmarshaling VPP token") - } - - info := fleet.VPPTokenInfo{ - Location: tokenData.Location, - RenewDate: rawToken.ExpDate, - OrgName: rawToken.OrgName, - } - - return &info, nil -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE /mdm/apple/vpp_token -//////////////////////////////////////////////////////////////////////////////// - -type deleteMDMAppleVPPTokenRequest struct{} - -type deleteMDMAppleVPPTokenResponse struct { - Err error `json:"error,omitempty"` -} - -func (r deleteMDMAppleVPPTokenResponse) error() error { return r.Err } - -func (r deleteMDMAppleVPPTokenResponse) Status() int { return http.StatusNoContent } - -func deleteMDMAppleVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - if err := svc.DeleteMDMAppleVPPToken(ctx); err != nil { - return &deleteMDMAppleVPPTokenResponse{Err: err}, nil - } - - return &deleteMDMAppleVPPTokenResponse{}, nil -} - -func (svc *Service) DeleteMDMAppleVPPToken(ctx context.Context) error { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return err - } - - if err := svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetVPPToken}); err != nil { - return ctxerr.Wrap(ctx, err, "delete VPP token") - } - - act := fleet.ActivityDisabledVPP{} - if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for delete VPP token") - } - - return nil -} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 92e3b3db31..3a626af027 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -123,6 +123,16 @@ func TestMDMAppleAuthorization(t *testing.T) { return nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return nil, nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return nil, nil + } + ds.GetVPPTokenFunc = func(ctx context.Context, id uint) (*fleet.VPPTokenDB, error) { + return nil, ¬FoundErr{} + } + ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return nil } // use a custom implementation of checkAuthErr as the service call will fail @@ -158,13 +168,13 @@ func TestMDMAppleAuthorization(t *testing.T) { err = svc.DeleteMDMAppleAPNSCert(ctx) // Don't expect anything other than an authz error here, since this is pretty much just a DB wrapper. checkAuthErr(t, shouldFailWithAuth, err) - err = svc.UploadMDMAppleVPPToken(ctx, nil) + _, err = svc.UploadVPPToken(ctx, nil) checkAuthErr(t, shouldFailWithAuth, err) - _, err = svc.GetMDMAppleVPPToken(ctx) + _, err = svc.GetVPPTokens(ctx) checkAuthErr(t, shouldFailWithAuth, err) - err = svc.DeleteMDMAppleVPPToken(ctx) + err = svc.DeleteVPPToken(ctx, 0) checkAuthErr(t, shouldFailWithAuth, err) } @@ -1066,8 +1076,10 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { ds.ListMDMConfigProfilesFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } checkShouldFail := func(t *testing.T, err error, shouldFail bool) { @@ -1140,8 +1152,10 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { cp.ProfileUUID = uuid.New().String() return &cp, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cases := []struct { @@ -1223,16 +1237,20 @@ func TestMDMBatchSetProfiles(t *testing.T) { ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { return &fleet.Team{ID: id, Name: "team"}, nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } testCases := []struct { diff --git a/server/service/orbit.go b/server/service/orbit.go index 1ee8725a08..bbc6c437e0 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -10,6 +10,7 @@ import ( "net/url" "github.com/fleetdm/fleet/v4/server" + "github.com/fleetdm/fleet/v4/server/contexts/capabilities" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -202,8 +203,13 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro notifs.RenewEnrollmentProfile = true } + manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, isConnectedToFleetMDM) + if err != nil { + return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking manual migration eligibility") + } + if appConfig.MDM.MacOSMigration.Enable && - fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) { + (fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) || manualMigrationEligible) { notifs.NeedsMDMMigration = true } @@ -428,6 +434,17 @@ func (svc *Service) setDiskEncryptionNotifications( switch host.FleetPlatform() { case "darwin": + mp, ok := capabilities.FromContext(ctx) + if !ok { + level.Debug(svc.logger).Log("msg", "no capabilities in context, skipping disk encryption notification") + return nil + } + + if !mp.Has(fleet.CapabilityEscrowBuddy) { + level.Debug(svc.logger).Log("msg", "host doesn't support Escrow Buddy, skipping disk encryption notification", "host_uuid", host.UUID) + return nil + } + notifs.RotateDiskEncryptionKey = encryptionKey != nil && encryptionKey.Decryptable != nil && !*encryptionKey.Decryptable case "windows": isServer := mdmInfo != nil && mdmInfo.IsServer @@ -668,7 +685,7 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host // always use the authenticated host's ID as host_id result.HostID = host.ID - hsr, err := svc.ds.SetHostScriptExecutionResult(ctx, result) + hsr, action, err := svc.ds.SetHostScriptExecutionResult(ctx, result) if err != nil { return ctxerr.Wrap(ctx, err, "save host script result") } @@ -690,20 +707,47 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host scriptName = scr.Name } - // TODO(sarah): We may need to special case lock/unlock script results here? - if err := svc.NewActivity( - ctx, - user, - fleet.ActivityTypeRanScript{ - HostID: host.ID, - HostDisplayName: host.DisplayName(), - ScriptExecutionID: hsr.ExecutionID, - ScriptName: scriptName, - Async: !hsr.SyncRequest, - }, - ); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for script execution request") + switch action { + case "uninstall": + // Get software title from execution ID + softwareTitleName, err := svc.ds.GetSoftwareTitleNameFromExecutionID(ctx, hsr.ExecutionID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software title from execution ID") + } + activityStatus := "failed" + if hsr.ExitCode != nil && *hsr.ExitCode == 0 { + activityStatus = "uninstalled" + } + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityTypeUninstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: softwareTitleName, + ExecutionID: hsr.ExecutionID, + Status: activityStatus, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for script execution request") + } + default: + // TODO(sarah): We may need to special case lock/unlock script results here? + if err := svc.NewActivity( + ctx, + user, + fleet.ActivityTypeRanScript{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + ScriptExecutionID: hsr.ExecutionID, + ScriptName: scriptName, + Async: !hsr.SyncRequest, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for script execution request") + } } + } return nil } @@ -975,17 +1019,37 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "save host software installation result") } - if status := result.Status(); status != fleet.SoftwareInstallerPending { + if status := result.Status(); status != fleet.SoftwareInstallPending { hsi, err := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID) if err != nil { return ctxerr.Wrap(ctx, err, "get host software installation result information") } + // Self-Service packages will have a nil author for the activity. var user *fleet.User - if hsi.UserID != nil && !hsi.SelfService { - user, err = svc.ds.UserByID(ctx, *hsi.UserID) - if err != nil { - return ctxerr.Wrap(ctx, err, "get host software installation user") + if !hsi.SelfService { + if hsi.UserID != nil { + user, err = svc.ds.UserByID(ctx, *hsi.UserID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host software installation user") + } + } else { + // hsi.UserID can be nil if the user was deleted and/or if the installation was + // triggered by Fleet (policy automation). Thus we set the author of the installation + // to be the user that uploaded the package (by design). + var userID uint + if hsi.SoftwareInstallerUserID != nil { + userID = *hsi.SoftwareInstallerUserID + } + // If there's no name or email then this may be a package uploaded + // before we added authorship to uploaded packages. + if hsi.SoftwareInstallerUserName != "" && hsi.SoftwareInstallerUserEmail != "" { + user = &fleet.User{ + ID: userID, + Name: hsi.SoftwareInstallerUserName, + Email: hsi.SoftwareInstallerUserEmail, + } + } } } diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index fb4fe0aa2f..5b67a08f45 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -7,7 +7,9 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" + "mime" "net" "net/http" "net/http/httptrace" @@ -146,7 +148,7 @@ func NewOrbitClient( orbitHostInfo fleet.OrbitHostInfo, onGetConfigErrFns *OnGetConfigErrFuncs, ) (*OrbitClient, error) { - orbitCapabilities := fleet.CapabilityMap{} + orbitCapabilities := fleet.GetOrbitClientCapabilities() bc, err := newBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCert, orbitCapabilities) if err != nil { return nil, err @@ -410,6 +412,31 @@ func (oc *OrbitClient) DownloadSoftwareInstaller(installerID uint, downloadDirec return resp.GetFilePath(), nil } +type NullFileResponse struct { +} + +func (f *NullFileResponse) Handle(resp *http.Response) error { + _, _, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) + if err != nil { + return fmt.Errorf("parsing media type from response header: %w", err) + } + _, err = io.Copy(io.Discard, resp.Body) + if err != nil { + return fmt.Errorf("copying from http stream to io.Discard: %w", err) + } + return nil +} + +// DownloadAndDiscardSoftwareInstaller downloads the software installer and discards it. +// This method is used during load testing by osquery-perf. +func (oc *OrbitClient) DownloadAndDiscardSoftwareInstaller(installerID uint) error { + verb, path := "POST", "/api/fleet/orbit/software_install/package?alt=media" + resp := NullFileResponse{} + return oc.authenticatedRequest(verb, path, &orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + }, &resp) +} + // Ping sends a ping request to the orbit/ping endpoint. func (oc *OrbitClient) Ping() error { verb, path := "HEAD", "/api/fleet/orbit/ping" diff --git a/server/service/osquery.go b/server/service/osquery.go index c4112f7d0d..de5c1e2e26 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "regexp" + "sort" "strconv" "strings" "sync/atomic" @@ -81,7 +82,7 @@ func (svc *Service) AuthenticateHost(ctx context.Context, nodeKey string) (*flee case err == nil: // OK case fleet.IsNotFound(err): - return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key: " + nodeKey) + return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key") default: return nil, false, newOsqueryError("authentication error: " + err.Error()) } @@ -955,7 +956,7 @@ func (svc *Service) SubmitDistributedQueryResults( svc.maybeDebugHost(ctx, host, results, statuses, messages, stats) - preProcessSoftwareResults(host.ID, &results, &statuses, &messages, osquery_utils.SoftwareOverrideQueries, svc.logger) + preProcessSoftwareResults(host, &results, &statuses, &messages, osquery_utils.SoftwareOverrideQueries, svc.logger) var hostWithoutPolicies bool for query, rows := range results { @@ -1008,6 +1009,10 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil { + logging.WithErr(ctx, err) + } + // filter policy results for webhooks var policyIDs []uint if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) { @@ -1038,6 +1043,7 @@ func (svc *Service) SubmitDistributedQueryResults( }() } } + // NOTE(mna): currently, failing policies webhook wouldn't see the new // flipped policies on the next run if async processing is enabled and the // collection has not been done yet (not persisted in mysql). Should @@ -1227,20 +1233,100 @@ func getFailingCalendarPolicies(policyResults map[uint]*bool, calendarPolicies [ // We do this to not grow the main software queries and to ingest // all software together (one direct ingest function for all software). func preProcessSoftwareResults( - hostID uint, + host *fleet.Host, results *fleet.OsqueryDistributedQueryResults, statuses *map[string]fleet.OsqueryStatus, messages *map[string]string, overrides map[string]osquery_utils.DetailQuery, logger log.Logger, ) { + // vsCodeExtensionsExtraQuery := hostDetailQueryPrefix + "software_vscode_extensions" - preProcessSoftwareExtraResults(vsCodeExtensionsExtraQuery, hostID, results, statuses, messages, osquery_utils.DetailQuery{}, logger) + preProcessSoftwareExtraResults(vsCodeExtensionsExtraQuery, host.ID, results, statuses, messages, osquery_utils.DetailQuery{}, logger) for name, query := range overrides { fullQueryName := hostDetailQueryPrefix + "software_" + name - preProcessSoftwareExtraResults(fullQueryName, hostID, results, statuses, messages, query, logger) + preProcessSoftwareExtraResults(fullQueryName, host.ID, results, statuses, messages, query, logger) } + + // Filter out python packages that are also deb packages on ubuntu + pythonPackageFilter(host.Platform, results, statuses) +} + +// pythonPackageFilter filters out duplicate python_packages that are installed under deb_packages on Ubuntu. +// python_packages not matching a Debian package names are updated to "python3-packagename" to match OVAL definitions. +func pythonPackageFilter(platform string, results *fleet.OsqueryDistributedQueryResults, statuses *map[string]fleet.OsqueryStatus) { + const pythonPrefix = "python3-" + const pythonSource = "python_packages" + const debSource = "deb_packages" + const linuxSoftware = hostDetailQueryPrefix + "software_linux" + + // Return early if platform is not Ubuntu + // We may need to add more platforms in the future + if platform != "ubuntu" { + return + } + + // Check the 'software_linux' result and status + sw, ok := (*results)[linuxSoftware] + if !ok { + return + } + if status, ok := (*statuses)[linuxSoftware]; !ok || status != fleet.StatusOK { + return + } + + // Extract the Python and Debian packages from the software list for filtering + // pre-allocating space for 40 packages based on number of package found in + // a fresh ubuntu 24.04 install + pythonPackages := make(map[string]int, 40) + debPackages := make(map[string]struct{}, 40) + + // Track indexes of rows to remove + indexesToRemove := []int{} + + for i, row := range sw { + switch row["source"] { + case pythonSource: + loweredName := strings.ToLower(row["name"]) + pythonPackages[loweredName] = i + row["name"] = loweredName + case debSource: + // Only append python3 deb packages + if strings.HasPrefix(row["name"], pythonPrefix) { + debPackages[row["name"]] = struct{}{} + } + } + } + + // Return early if there are no Python packages to process + if len(pythonPackages) == 0 { + return + } + + // Loop through pythonPackages map to identify any that should be removed + for name, index := range pythonPackages { + convertedName := pythonPrefix + name + + // Filter out Python packages that are also Debian packages + if _, found := debPackages[convertedName]; found { + indexesToRemove = append(indexesToRemove, index) + } else { + // Update remaining Python package names to match OVAL definitions + sw[index]["name"] = convertedName + } + } + + // Sort indexes to remove in descending order + sort.Sort(sort.Reverse(sort.IntSlice(indexesToRemove))) + + // Remove rows from sw in descending order of indexes + for _, index := range indexesToRemove { + sw = append(sw[:index], sw[index+1:]...) + } + + // Store the updated software result back in the results map + (*results)[linuxSoftware] = sw } func preProcessSoftwareExtraResults( @@ -1606,6 +1692,144 @@ func (svc *Service) registerFlippedPolicies(ctx context.Context, hostID uint, ho return nil } +func (svc *Service) processSoftwareForNewlyFailingPolicies( + ctx context.Context, + hostID uint, + hostTeamID *uint, + hostPlatform string, + hostOrbitNodeKey *string, + incomingPolicyResults map[uint]*bool, +) error { + if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" { + // We do not want to queue software installations on vanilla osquery hosts. + return nil + } + + var policyTeamID uint + if hostTeamID == nil { + policyTeamID = fleet.PolicyNoTeamID + } else { + policyTeamID = *hostTeamID + } + + // Filter out results that are not failures (we are only interested on failing policies, + // we don't care about passing policies or policies that failed to execute). + incomingFailingPolicies := make(map[uint]*bool) + var incomingFailingPoliciesIDs []uint + for policyID, policyResult := range incomingPolicyResults { + if policyResult != nil && !*policyResult { + incomingFailingPolicies[policyID] = policyResult + incomingFailingPoliciesIDs = append(incomingFailingPoliciesIDs, policyID) + } + } + if len(incomingFailingPolicies) == 0 { + return nil + } + + // Get policies with associated installers for the team. + policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, policyTeamID, incomingFailingPoliciesIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get policies with installer") + } + if len(policiesWithInstaller) == 0 { + return nil + } + + // Filter out results of policies that are not associated to installers. + policiesWithInstallersMap := make(map[uint]fleet.PolicySoftwareInstallerData) + for _, policyWithInstaller := range policiesWithInstaller { + policiesWithInstallersMap[policyWithInstaller.ID] = policyWithInstaller + } + policyResultsOfPoliciesWithInstallers := make(map[uint]*bool) + for policyID, passes := range incomingFailingPolicies { + if _, ok := policiesWithInstallersMap[policyID]; !ok { + continue + } + policyResultsOfPoliciesWithInstallers[policyID] = passes + } + if len(policyResultsOfPoliciesWithInstallers) == 0 { + return nil + } + + // Get the policies associated with installers that are flipping from passing to failing on this host. + policyIDsOfNewlyFailingPoliciesWithInstallers, _, err := svc.ds.FlippingPoliciesForHost( + ctx, hostID, policyResultsOfPoliciesWithInstallers, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get flipping policies for host") + } + if len(policyIDsOfNewlyFailingPoliciesWithInstallers) == 0 { + return nil + } + policyIDsOfNewlyFailingPoliciesWithInstallersSet := make(map[uint]struct{}) + for _, policyID := range policyIDsOfNewlyFailingPoliciesWithInstallers { + policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyID] = struct{}{} + } + + // Finally filter out policies with installers that are not newly failing. + var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData + for _, policyWithInstaller := range policiesWithInstaller { + if _, ok := policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyWithInstaller.ID]; ok { + failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller) + } + } + + for _, failingPolicyWithInstaller := range failingPoliciesWithInstaller { + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, failingPolicyWithInstaller.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + logger := log.With(svc.logger, + "host_id", hostID, + "host_platform", hostPlatform, + "policy_id", failingPolicyWithInstaller.ID, + "software_installer_id", failingPolicyWithInstaller.InstallerID, + "software_title_id", installerMetadata.TitleID, + "software_installer_platform", installerMetadata.Platform, + ) + if fleet.PlatformFromHost(hostPlatform) != installerMetadata.Platform { + level.Debug(logger).Log("msg", "installer platform does not match host platform") + continue + } + hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host last install data") + } + // hostLastInstall.Status == nil can happen when a software is installed by Fleet and later removed. + if hostLastInstall != nil && hostLastInstall.Status != nil && + *hostLastInstall.Status == fleet.SoftwareInstallPending { + // There's a pending install for this host and installer, + // thus we do not queue another install request. + level.Debug(svc.logger).Log( + "msg", "found pending install request for this host and installer", + "pending_execution_id", hostLastInstall.ExecutionID, + ) + continue + } + // NOTE(lucas): The user_id set in this software install will be NULL + // so this means that when generating the activity for this action + // (in SaveHostSoftwareInstallResult) + // the author will be set to the user that uploaded the software (we want this + // by design). + installUUID, err := svc.ds.InsertSoftwareInstallRequest( + ctx, hostID, + installerMetadata.InstallerID, + false, // Set Self-service as false because this is triggered by Fleet. + ) + if err != nil { + return ctxerr.Wrapf(ctx, err, + "insert software install request: host_id=%d, software_installer_id=%d", + hostID, installerMetadata.InstallerID, + ) + } + level.Debug(logger).Log( + "msg", "install request sent", + "install_uuid", installUUID, + ) + } + return nil +} + func (svc *Service) maybeDebugHost( ctx context.Context, host *fleet.Host, diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 8ceb4ec77b..0697c7de69 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -3678,8 +3678,8 @@ func TestPreProcessSoftwareResults(t *testing.T) { } for _, tc := range []struct { - name string - + name string + host *fleet.Host resultsIn fleet.OsqueryDistributedQueryResults statusesIn map[string]fleet.OsqueryStatus messagesIn map[string]string @@ -3898,10 +3898,134 @@ func TestPreProcessSoftwareResults(t *testing.T) { }, }, }, + { + name: "ubuntu dpkg installed python packages are filtered out", + host: &fleet.Host{ID: 1, Platform: "ubuntu"}, + statusesIn: map[string]fleet.OsqueryStatus{ + hostDetailQueryPrefix + "software_linux": fleet.StatusOK, + }, + resultsIn: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_linux": []map[string]string{ + { + "name": "python3-twisted", + "version": "20.3.0-2", + "source": "deb_packages", + }, + { + "name": "Twisted", // duplicate of python3-twisted + "version": "20.3.0-2", + "source": "python_packages", + }, + { + "name": "python3-setuptools", + "version": "50.3.2", + "source": "deb_packages", + }, + { + "name": "setuptools", + "version": "50.3.2", + "source": "python_packages", + }, + { + "name": "pillow", + "version": "8.1.0", + "source": "python_packages", + }, + { + "name": "python3-urllib3", + "version": "1.26.2-2", + "source": "deb_packages", + }, + }, + }, + resultsOut: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_linux": []map[string]string{ + { + "name": "python3-twisted", + "version": "20.3.0-2", + "source": "deb_packages", + }, + { + "name": "python3-setuptools", + "version": "50.3.2", + "source": "deb_packages", + }, + { + "name": "python3-pillow", // renamed from pillow + "version": "8.1.0", + "source": "python_packages", + }, + { + "name": "python3-urllib3", + "version": "1.26.2-2", + "source": "deb_packages", + }, + }, + }, + }, + { + name: "non-ubuntu installed python packages are NOT filtered out", + host: &fleet.Host{ID: 1, Platform: "rhel"}, + statusesIn: map[string]fleet.OsqueryStatus{ + hostDetailQueryPrefix + "software_linux": fleet.StatusOK, + }, + resultsIn: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_linux": []map[string]string{ + { + "name": "python3-twisted", + "version": "20.3.0-2", + "source": "rpm_packages", + }, + { + "name": "twisted", // duplicate of python3-twisted + "version": "20.3.0-2", + "source": "python_packages", + }, + { + "name": "pillow", + "version": "8.1.0", + "source": "python_packages", + }, + { + "name": "python3-urllib3", + "version": "1.26.2-2", + "source": "rpm_packages", + }, + }, + }, + resultsOut: fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_linux": []map[string]string{ + { + "name": "python3-twisted", + "version": "20.3.0-2", + "source": "rpm_packages", + }, + { + "name": "twisted", // duplicate of python3-twisted + "version": "20.3.0-2", + "source": "python_packages", + }, + { + "name": "pillow", + "version": "8.1.0", + "source": "python_packages", + }, + { + "name": "python3-urllib3", + "version": "1.26.2-2", + "source": "rpm_packages", + }, + }, + }, + }, } { tc := tc t.Run(tc.name, func(t *testing.T) { - preProcessSoftwareResults(1, &tc.resultsIn, &tc.statusesIn, &tc.messagesIn, tc.overrides, log.NewNopLogger()) + host := &fleet.Host{ID: 1} + if tc.host != nil { + host = tc.host + } + preProcessSoftwareResults(host, &tc.resultsIn, &tc.statusesIn, &tc.messagesIn, tc.overrides, log.NewNopLogger()) require.Equal(t, tc.resultsOut, tc.resultsIn) }) } @@ -3943,3 +4067,47 @@ func BenchmarkFindPackDelimiterStringTeamPack(b *testing.B) { findPackDelimiterString(input) } } + +func mockUbuntuResults() *fleet.OsqueryDistributedQueryResults { + results := &fleet.OsqueryDistributedQueryResults{ + hostDetailQueryPrefix + "software_linux": make([]map[string]string, 0), + } + + // Adding 40 python packages with matching deb packages + // Adding 2 python packages without matching deb packages + for i := 1; i <= 42; i++ { + pythonPkg := fmt.Sprintf("package%d", i) + (*results)[hostDetailQueryPrefix+"software_linux"] = append((*results)[hostDetailQueryPrefix+"software_linux"], map[string]string{ + "source": "python_packages", + "name": pythonPkg, + }) + } + + // Adding 1500 deb packages, with the first 40 matching python packages + for i := 1; i <= 1500; i++ { + var debPkg string + if i <= 38 { // Match first 38 python packages + debPkg = fmt.Sprintf("python3-package%d", i) + } else { // Non-python packages + debPkg = fmt.Sprintf("unrelated_package%d", i) + } + (*results)[hostDetailQueryPrefix+"software_linux"] = append((*results)[hostDetailQueryPrefix+"software_linux"], map[string]string{ + "source": "deb_packages", + "name": debPkg, + }) + } + + return results +} + +func BenchmarkPreprocessUbuntuPythonPackageFilter(b *testing.B) { + platform := "ubuntu" + results := mockUbuntuResults() + statuses := &map[string]fleet.OsqueryStatus{ + hostDetailQueryPrefix + "software_linux": fleet.StatusOK, + } + + for i := 0; i < b.N; i++ { + preProcessSoftwareResults(&fleet.Host{ID: 1, Platform: platform}, results, statuses, nil, nil, log.NewNopLogger()) + } +} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 59b2430b54..f13454b2d1 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -1599,6 +1599,31 @@ func sanitizeSoftware(h *fleet.Host, s *fleet.Software, logger log.Logger) { s.Version = strings.Join(newParts, ".") }, }, + { + // Trim the "RELEASE." prefix from Minio versions. + checkSoftware: func(h *fleet.Host, s *fleet.Software) bool { + return s.Name == "minio" && strings.Contains(s.Version, "RELEASE.") + }, + mutateSoftware: func(s *fleet.Software) { + s.Version = strings.TrimPrefix(s.Version, "RELEASE.") + }, + }, + { + // Convert the timestamp to NVD's format for Minio versions. + checkSoftware: func(h *fleet.Host, s *fleet.Software) bool { + regex := regexp.MustCompile(`^\d{14}$`) + + return s.Name == "minio" && regex.MatchString(s.Version) + }, + mutateSoftware: func(s *fleet.Software) { + timestamp, err := time.Parse("20060102150405", s.Version) + if err != nil { + level.Debug(logger).Log("msg", "failed to parse software version", "name", s.Name, "version", s.Version, "err", err) + return + } + s.Version = timestamp.Format("2006-01-02T15-04-05Z") + }, + }, } for _, softwareSanitizer := range softwareSanitizers { @@ -2006,7 +2031,7 @@ func directIngestMDMDeviceIDWindows(ctx context.Context, logger log.Logger, host return ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, rows[0]["data"]) } -//go:generate go run gen_queries_doc.go "../../../docs/Using Fleet/Understanding-host-vitals.md" +//go:generate go run gen_queries_doc.go "../../../docs/Contributing/Understanding-host-vitals.md" func GetDetailQueries( ctx context.Context, diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 8fb86bcc1f..8a29314470 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -1830,6 +1830,30 @@ func TestSanitizeSoftware(t *testing.T) { Version: "1.6.00.34263", }, }, + { + name: "minio", + h: &fleet.Host{}, + s: &fleet.Software{ + Name: "minio", + Version: "RELEASE.2022-03-10T00-00-00Z", + }, + sanitized: &fleet.Software{ + Name: "minio", + Version: "2022-03-10T00-00-00Z", + }, + }, + { + name: "minio", + h: &fleet.Host{}, + s: &fleet.Software{ + Name: "minio", + Version: "20200310000000", + }, + sanitized: &fleet.Software{ + Name: "minio", + Version: "2020-03-10T00-00-00Z", + }, + }, } { t.Run(tc.name, func(t *testing.T) { sanitizeSoftware(tc.h, tc.s, log.NewNopLogger()) diff --git a/server/service/redis_key_value/redis_key_value.go b/server/service/redis_key_value/redis_key_value.go new file mode 100644 index 0000000000..010c24c19c --- /dev/null +++ b/server/service/redis_key_value/redis_key_value.go @@ -0,0 +1,58 @@ +// Package redis_key_value implements a most basic SET & GET key/value store +// where both the key and the value are strings. +package redis_key_value + +import ( + "context" + "errors" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/datastore/redis" + "github.com/fleetdm/fleet/v4/server/fleet" + redigo "github.com/gomodule/redigo/redis" +) + +// RedisKeyValue is a basic key/value store with SET and GET operations +// Items are removed via expiration (defined in the SET operation). +type RedisKeyValue struct { + pool fleet.RedisPool + testPrefix string // for tests, the key prefix to use to avoid conflicts +} + +// New creates a new RedisKeyValue store. +func New(pool fleet.RedisPool) *RedisKeyValue { + return &RedisKeyValue{pool: pool} +} + +// prefix is used to not collide with other key domains (like live queries or calendar locks). +const prefix = "key_value_" + +// Set creates or overrides the given key with the given value. +// Argument expireTime is used to set the expiration of the item +// (when updating, the expiration of the item is updated). +func (r *RedisKeyValue) Set(ctx context.Context, key string, value string, expireTime time.Duration) error { + conn := redis.ConfigureDoer(r.pool, r.pool.Get()) + defer conn.Close() + + if _, err := redigo.String(conn.Do("SET", r.testPrefix+prefix+key, value, "PX", expireTime.Milliseconds())); err != nil { + return ctxerr.Wrap(ctx, err, "redis failed to set") + } + return nil +} + +// Get returns the value for a given key. +// It returns (nil, nil) if the key doesn't exist. +func (r *RedisKeyValue) Get(ctx context.Context, key string) (*string, error) { + conn := redis.ConfigureDoer(r.pool, r.pool.Get()) + defer conn.Close() + + res, err := redigo.String(conn.Do("GET", r.testPrefix+prefix+key)) + if errors.Is(err, redigo.ErrNil) { + return nil, nil + } + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "redis failed to get") + } + return &res, nil +} diff --git a/server/service/redis_key_value/redis_key_value_test.go b/server/service/redis_key_value/redis_key_value_test.go new file mode 100644 index 0000000000..5f410e4a49 --- /dev/null +++ b/server/service/redis_key_value/redis_key_value_test.go @@ -0,0 +1,92 @@ +package redis_key_value + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +func TestRedisKeyValue(t *testing.T) { + for _, f := range []func(*testing.T, *RedisKeyValue){ + testSetGet, + } { + t.Run(test.FunctionName(f), func(t *testing.T) { + t.Run("standalone", func(t *testing.T) { + kv := setupRedis(t, false, false) + f(t, kv) + }) + t.Run("cluster", func(t *testing.T) { + kv := setupRedis(t, true, true) + f(t, kv) + }) + }) + } +} + +func setupRedis(t testing.TB, cluster, redir bool) *RedisKeyValue { + pool := redistest.SetupRedis(t, t.Name(), cluster, redir, true) + return newRedisKeyValueForTest(t, pool) +} + +type testName interface { + Name() string +} + +func newRedisKeyValueForTest(t testName, pool fleet.RedisPool) *RedisKeyValue { + return &RedisKeyValue{ + pool: pool, + testPrefix: t.Name() + ":", + } +} + +func testSetGet(t *testing.T, kv *RedisKeyValue) { + ctx := context.Background() + + result, err := kv.Get(ctx, "foo") + require.NoError(t, err) + require.Nil(t, result) + + err = kv.Set(ctx, "foo", "bar", 5*time.Second) + require.NoError(t, err) + + result, err = kv.Get(ctx, "foo") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "bar", *result) + + err = kv.Set(ctx, "foo", "zoo", 5*time.Second) + require.NoError(t, err) + + result, err = kv.Get(ctx, "foo") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "zoo", *result) + + err = kv.Set(ctx, "boo", "bar", 2*time.Second) + require.NoError(t, err) + result, err = kv.Get(ctx, "boo") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "bar", *result) + + time.Sleep(3 * time.Second) + result, err = kv.Get(ctx, "boo") + require.NoError(t, err) + require.Nil(t, result) + + // Updating an item, updates the expiration time. + err = kv.Set(ctx, "test", "foo", 2*time.Second) + require.NoError(t, err) + err = kv.Set(ctx, "test", "foo", 10*time.Second) + require.NoError(t, err) + time.Sleep(5 * time.Second) + result, err = kv.Get(ctx, "test") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "foo", *result) +} diff --git a/server/service/redis_lock/redis_lock.go b/server/service/redis_lock/redis_lock.go index a5443f1df8..0c5e17db17 100644 --- a/server/service/redis_lock/redis_lock.go +++ b/server/service/redis_lock/redis_lock.go @@ -29,7 +29,7 @@ func NewLock(pool fleet.RedisPool) fleet.Lock { return fleet.Lock(lock) } -func (r *redisLock) AcquireLock(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) { +func (r *redisLock) SetIfNotExist(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) { conn := redis.ConfigureDoer(r.pool, r.pool.Get()) defer conn.Close() @@ -116,3 +116,25 @@ func (r *redisLock) Get(ctx context.Context, key string) (*string, error) { } return &res, nil } + +func (r *redisLock) GetAndDelete(ctx context.Context, key string) (*string, error) { + conn := redis.ConfigureDoer(r.pool, r.pool.Get()) + defer conn.Close() + + // Note: In Redis 6.2.0, this can be accomplished with a single command: GETDEL. + + res, err := redigo.String(conn.Do("GET", r.testPrefix+key)) + if errors.Is(err, redigo.ErrNil) { + return nil, nil + } + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "redis GET") + } + + _, err = conn.Do("DEL", r.testPrefix+key) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "redis DEL") + } + + return &res, nil +} diff --git a/server/service/redis_lock/redis_lock_test.go b/server/service/redis_lock/redis_lock_test.go index 74df2a8334..c6663cded4 100644 --- a/server/service/redis_lock/redis_lock_test.go +++ b/server/service/redis_lock/redis_lock_test.go @@ -2,13 +2,14 @@ package redis_lock import ( "context" + "testing" + "time" + "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" - "time" ) func TestRedisLock(t *testing.T) { @@ -50,12 +51,12 @@ func NewLockTest(t TestName, pool fleet.RedisPool) fleet.Lock { func testRedisAcquireLock(t *testing.T, lock fleet.Lock) { ctx := context.Background() - result, err := lock.AcquireLock(ctx, "test", "1", 0) + result, err := lock.SetIfNotExist(ctx, "test", "1", 0) require.NoError(t, err) assert.True(t, result) // Try to acquire the same lock - result, err = lock.AcquireLock(ctx, "test", "1", 0) + result, err = lock.SetIfNotExist(ctx, "test", "1", 0) assert.NoError(t, err) assert.False(t, result) @@ -75,7 +76,7 @@ func testRedisAcquireLock(t *testing.T, lock fleet.Lock) { assert.True(t, ok) // Acquire the lock again - result, err = lock.AcquireLock(ctx, "test", "1", 0) + result, err = lock.SetIfNotExist(ctx, "test", "1", 0) require.NoError(t, err) assert.True(t, result) @@ -87,14 +88,14 @@ func testRedisAcquireLock(t *testing.T, lock fleet.Lock) { // Try to set lock with expiration var expire uint64 = 10 - result, err = lock.AcquireLock(ctx, "testE", "1", expire) + result, err = lock.SetIfNotExist(ctx, "testE", "1", expire) require.NoError(t, err) assert.True(t, result) // Try to acquire the same lock after waiting duration := time.Duration(expire+1) * time.Millisecond time.Sleep(duration) - result, err = lock.AcquireLock(ctx, "testE", "1", 0) + result, err = lock.SetIfNotExist(ctx, "testE", "1", 0) require.NoError(t, err) assert.True(t, result) @@ -103,6 +104,26 @@ func testRedisAcquireLock(t *testing.T, lock fleet.Lock) { assert.NoError(t, err) assert.Nil(t, getResult) + // Get and delete non-existent key + getResult, err = lock.GetAndDelete(ctx, "testNonExistent") + assert.NoError(t, err) + assert.Nil(t, getResult) + + // Set a new item + result, err = lock.SetIfNotExist(ctx, "test2", "2", 0) + require.NoError(t, err) + assert.True(t, result) + + // Get and delete the item + getResult, err = lock.GetAndDelete(ctx, "test2") + assert.NoError(t, err) + require.NotNil(t, getResult) + assert.Equal(t, "2", *getResult) + + // Item was deleted, so we can't get it again + getResult, err = lock.Get(ctx, "test2") + assert.NoError(t, err) + assert.Nil(t, getResult) } func testRedisSet(t *testing.T, lock fleet.Lock) { diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go index 8965416a64..be6377c91b 100644 --- a/server/service/schedule/schedule.go +++ b/server/service/schedule/schedule.go @@ -47,6 +47,8 @@ type Schedule struct { jobs []Job statsStore CronStatsStore + + runOnce bool } // JobFn is the signature of a Job. @@ -120,6 +122,13 @@ func WithJob(id string, fn JobFn) Option { } } +// WithRunOnce sets the Schedule to run only once. +func WithRunOnce(once bool) Option { + return func(s *Schedule) { + s.runOnce = once + } +} + // New creates and returns a Schedule. // Jobs are added with the WithJob Option. // @@ -167,7 +176,16 @@ func (s *Schedule) Start() { level.Error(s.logger).Log("err", "start schedule", "details", err) ctxerr.Handle(s.ctx, err) } - s.setIntervalStartedAt(prevScheduledRun.CreatedAt) + + // if there is no previous run, set the start time to the current time. + startedAt := prevScheduledRun.CreatedAt + if startedAt.IsZero() { + startedAt = time.Now() + } else if s.runOnce && prevScheduledRun.Status == fleet.CronStatsStatusCompleted { + // If job is set to run once, and it already ran, then nothing to do + return + } + s.setIntervalStartedAt(startedAt) initialWait := 10 * time.Second if schedInterval := s.getSchedInterval(); schedInterval < initialWait { diff --git a/server/service/schedule/schedule_test.go b/server/service/schedule/schedule_test.go index ccbe80d1c0..c54bd807fa 100644 --- a/server/service/schedule/schedule_test.go +++ b/server/service/schedule/schedule_test.go @@ -590,6 +590,7 @@ func TestMultipleScheduleInstancesConfigChangesDS(t *testing.T) { } func TestTriggerSingleInstance(t *testing.T) { + t.Parallel() ctx, cancelFn := context.WithCancel(context.Background()) name := "test_trigger_single_instance" diff --git a/server/service/scripts.go b/server/service/scripts.go index 9629b78a09..14d39e4964 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -349,16 +349,17 @@ type getScriptResultRequest struct { } type getScriptResultResponse struct { - ScriptContents string `json:"script_contents"` - ScriptID *uint `json:"script_id"` - ExitCode *int64 `json:"exit_code"` - Output string `json:"output"` - Message string `json:"message"` - HostName string `json:"hostname"` - HostTimeout bool `json:"host_timeout"` - HostID uint `json:"host_id"` - ExecutionID string `json:"execution_id"` - Runtime int `json:"runtime"` + ScriptContents string `json:"script_contents"` + ScriptID *uint `json:"script_id"` + ExitCode *int64 `json:"exit_code"` + Output string `json:"output"` + Message string `json:"message"` + HostName string `json:"hostname"` + HostTimeout bool `json:"host_timeout"` + HostID uint `json:"host_id"` + ExecutionID string `json:"execution_id"` + Runtime int `json:"runtime"` + CreatedAt time.Time `json:"created_at"` Err error `json:"error,omitempty"` } @@ -388,6 +389,7 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet HostID: scriptResult.HostID, ExecutionID: scriptResult.ExecutionID, Runtime: scriptResult.Runtime, + CreatedAt: scriptResult.CreatedAt, }, nil } diff --git a/server/service/service_campaign_test.go b/server/service/service_campaign_test.go index d7ae34c98c..0878b0a9bc 100644 --- a/server/service/service_campaign_test.go +++ b/server/service/service_campaign_test.go @@ -3,7 +3,6 @@ package service import ( "context" "crypto/tls" - "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "math/rand" "net/http" "net/http/httptest" @@ -14,6 +13,7 @@ import ( "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" @@ -398,7 +398,7 @@ func TestUpdateStats(t *testing.T) { testUpdateStats(t, ds, false) } -func TestUpdateStatsOnReplica(t *testing.T) { +func TestIntegrationsUpdateStatsOnReplica(t *testing.T) { ds := mysql.CreateMySQLDSWithReplica(t, nil) defer mysql.TruncateTables(t, ds) testUpdateStats(t, ds, true) diff --git a/server/service/software.go b/server/service/software.go index 5289c951f5..e589ac7fe6 100644 --- a/server/service/software.go +++ b/server/service/software.go @@ -105,6 +105,11 @@ func (svc *Service) ListSoftware(ctx context.Context, opt fleet.SoftwareListOpti return nil, nil, err } + // Vulnerability filters are only available in premium (opt.IncludeCVEScores is only true in premium) + if !opt.IncludeCVEScores && (opt.MaximumCVSS > 0 || opt.MinimumCVSS > 0 || opt.KnownExploit) { + return nil, nil, fleet.ErrMissingLicense + } + // default sort order to hosts_count descending if opt.ListOptions.OrderKey == "" { opt.ListOptions.OrderKey = "hosts_count" @@ -226,5 +231,20 @@ func (svc Service) CountSoftware(ctx context.Context, opt fleet.SoftwareListOpti return 0, err } + lic, err := svc.License(ctx) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "get license") + } + + // Vulnerability filters are only available in premium + if !lic.IsPremium() && (opt.MaximumCVSS > 0 || opt.MinimumCVSS > 0 || opt.KnownExploit) { + return 0, fleet.ErrMissingLicense + } + + // required for vulnerability filters + if lic.IsPremium() { + opt.IncludeCVEScores = true + } + return svc.ds.CountSoftware(ctx, opt) } diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 5de426c2d6..b10b6a6f4c 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -2,12 +2,15 @@ package service import ( "context" + "crypto/x509" + "encoding/json" "errors" "fmt" "io" "mime/multipart" "net" "net/http" + "net/url" "strconv" "github.com/docker/go-units" @@ -26,6 +29,18 @@ type uploadSoftwareInstallerRequest struct { PreInstallQuery string PostInstallScript string SelfService bool + UninstallScript string +} + +type updateSoftwareInstallerRequest struct { + TitleID uint `url:"id"` + File *multipart.FileHeader + TeamID *uint + InstallScript *string + PreInstallQuery *string + PostInstallScript *string + UninstallScript *string + SelfService *bool } type uploadSoftwareInstallerResponse struct { @@ -33,13 +48,139 @@ type uploadSoftwareInstallerResponse struct { } // MaxSoftwareInstallerSize is the maximum size allowed for software -// installers. This is enforced by the endpoint that uploads installers. +// installers. This is enforced by the endpoints that upload installers. const MaxSoftwareInstallerSize = 500 * units.MiB +// TODO: We parse the whole body before running svc.authz.Authorize. +// An authenticated but unauthorized user could abuse this. +func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := updateSoftwareInstallerRequest{} + + // populate software title ID since we're overriding the decoder that would do it for us + titleID, err := uint32FromRequest(r, "id") + if err != nil { + return nil, badRequestErr("intFromRequest", err) + } + decoded.TitleID = uint(titleID) + + err = r.ParseMultipartForm(512 * units.MiB) + if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + return nil, &fleet.BadRequestError{ + Message: "The maximum file size is 500 MB.", + InternalErr: err, + } + } + var nerr net.Error + if errors.As(err, &nerr) && nerr.Timeout() { + return nil, fleet.NewUserMessageError( + ctxerr.New(ctx, "Couldn't upload. Please ensure your internet connection speed is sufficient and stable."), + http.StatusRequestTimeout, + ) + } + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form: " + err.Error(), + InternalErr: err, + } + } + + // unlike for uploadSoftwareInstallerRequest, every field is optional, including the file upload + if r.MultipartForm.File["software"] != nil || len(r.MultipartForm.File["software"]) > 0 { + decoded.File = r.MultipartForm.File["software"][0] + if decoded.File.Size > MaxSoftwareInstallerSize { + // Should never happen here since the request's body is limited to the maximum size. + return nil, &fleet.BadRequestError{ + Message: "The maximum file size is 500 MB.", + } + } + } + + // default is no team + val, ok := r.MultipartForm.Value["team_id"] + if ok { + teamID, err := strconv.ParseUint(val[0], 10, 32) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid team_id: %s", val[0])} + } + decoded.TeamID = ptr.Uint(uint(teamID)) + } + + installScriptMultipart, ok := r.MultipartForm.Value["install_script"] + if ok && len(installScriptMultipart) > 0 { + decoded.InstallScript = &installScriptMultipart[0] + } + + preinstallQueryMultipart, ok := r.MultipartForm.Value["pre_install_query"] + if ok && len(preinstallQueryMultipart) > 0 { + decoded.PreInstallQuery = &preinstallQueryMultipart[0] + } + + postInstallScriptMultipart, ok := r.MultipartForm.Value["post_install_script"] + if ok && len(postInstallScriptMultipart) > 0 { + decoded.PostInstallScript = &postInstallScriptMultipart[0] + } + + uninstallScriptMultipart, ok := r.MultipartForm.Value["uninstall_script"] + if ok && len(uninstallScriptMultipart) > 0 { + decoded.UninstallScript = &uninstallScriptMultipart[0] + } + + val, ok = r.MultipartForm.Value["self_service"] + if ok && len(val) > 0 && val[0] != "" { + parsed, err := strconv.ParseBool(val[0]) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())} + } + decoded.SelfService = &parsed + } + + return &decoded, nil +} + +func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*updateSoftwareInstallerRequest) + + payload := &fleet.UpdateSoftwareInstallerPayload{ + TitleID: req.TitleID, + TeamID: req.TeamID, + InstallScript: req.InstallScript, + PreInstallQuery: req.PreInstallQuery, + PostInstallScript: req.PostInstallScript, + UninstallScript: req.UninstallScript, + SelfService: req.SelfService, + } + if req.File != nil { + ff, err := req.File.Open() + if err != nil { + return uploadSoftwareInstallerResponse{Err: err}, nil + } + defer ff.Close() + payload.InstallerFile = ff + payload.Filename = req.File.Filename + } + + installer, err := svc.UpdateSoftwareInstaller(ctx, payload) + if err != nil { + return uploadSoftwareInstallerResponse{Err: err}, nil + } + + return getSoftwareInstallerResponse{SoftwareInstaller: installer}, nil +} + +func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + // TODO: We parse the whole body before running svc.authz.Authorize. // An authenticated but unauthorized user could abuse this. func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { decoded := uploadSoftwareInstallerRequest{} + err := r.ParseMultipartForm(512 * units.MiB) if err != nil { var mbe *http.MaxBytesError @@ -93,6 +234,11 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.InstallScript = val[0] } + val, ok = r.MultipartForm.Value["uninstall_script"] + if ok && len(val) > 0 { + decoded.UninstallScript = val[0] + } + val, ok = r.MultipartForm.Value["pre_install_query"] if ok && len(val) > 0 { decoded.PreInstallQuery = val[0] @@ -133,6 +279,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s InstallerFile: ff, Filename: req.File.Filename, SelfService: req.SelfService, + UninstallScript: req.UninstallScript, } if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil { @@ -184,23 +331,15 @@ type getSoftwareInstallerRequest struct { TitleID uint `url:"title_id"` } -type getSoftwareInstallerResponse struct { - // meta *fleet.SoftwareInstaller // NOTE: API design currently only supports downloading the - Err error `json:"error,omitempty"` +type downloadSoftwareInstallerRequest struct { + TitleID uint `url:"title_id"` + Token string `url:"token"` } -func (r getSoftwareInstallerResponse) error() error { return r.Err } - func getSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getSoftwareInstallerRequest) - downloadRequested := req.Alt == "media" - if !downloadRequested { - // TODO: confirm error handling - return getSoftwareInstallerResponse{Err: &fleet.BadRequestError{Message: "only alt=media is supported"}}, nil - } - - payload, err := svc.DownloadSoftwareInstaller(ctx, req.TitleID, req.TeamID) + payload, err := svc.DownloadSoftwareInstaller(ctx, false, req.Alt, req.TitleID, req.TeamID) if err != nil { return orbitDownloadSoftwareInstallerResponse{Err: err}, nil } @@ -208,7 +347,43 @@ func getSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil } -func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*fleet.SoftwareInstaller, error) { +func getSoftwareInstallerTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getSoftwareInstallerRequest) + + token, err := svc.GenerateSoftwareInstallerToken(ctx, req.Alt, req.TitleID, req.TeamID) + if err != nil { + return getSoftwareInstallerTokenResponse{Err: err}, nil + } + return getSoftwareInstallerTokenResponse{Token: token}, nil +} + +func downloadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*downloadSoftwareInstallerRequest) + + meta, err := svc.GetSoftwareInstallerTokenMetadata(ctx, req.Token, req.TitleID) + if err != nil { + return orbitDownloadSoftwareInstallerResponse{Err: err}, nil + } + + payload, err := svc.DownloadSoftwareInstaller(ctx, true, "media", meta.TitleID, &meta.TeamID) + if err != nil { + return orbitDownloadSoftwareInstallerResponse{Err: err}, nil + } + + return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil +} + +func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, _ string, _ uint, _ *uint) (string, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return "", fleet.ErrMissingLicense +} + +func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, _ string, _ uint) (*fleet.SoftwareInstallerTokenMetadata, + error, +) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) @@ -216,6 +391,28 @@ func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, titleID ui return nil, fleet.ErrMissingLicense } +func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, _ bool, _ uint, _ *uint) (*fleet.SoftwareInstaller, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +type getSoftwareInstallerResponse struct { + SoftwareInstaller *fleet.SoftwareInstaller `json:"software_installer,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r getSoftwareInstallerResponse) error() error { return r.Err } + +type getSoftwareInstallerTokenResponse struct { + Err error `json:"error,omitempty"` + Token string `json:"token"` +} + +func (r getSoftwareInstallerTokenResponse) error() error { return r.Err } + type orbitDownloadSoftwareInstallerResponse struct { Err error `json:"error,omitempty"` // fields used by hijackRender for the response. @@ -239,7 +436,10 @@ func (r orbitDownloadSoftwareInstallerResponse) hijackRender(ctx context.Context r.payload.Installer.Close() } -func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) { +func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, _ bool, _ string, _ uint, + _ *uint) (*fleet.DownloadSoftwareInstallerPayload, + error, +) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) @@ -283,6 +483,28 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw return fleet.ErrMissingLicense } +type uninstallSoftwareRequest struct { + HostID uint `url:"host_id"` + SoftwareTitleID uint `url:"software_title_id"` +} + +func uninstallSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*uninstallSoftwareRequest) + + err := svc.UninstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID) + if err != nil { + return installSoftwareResponse{Err: err}, nil + } + + return installSoftwareResponse{}, nil +} + +func (svc *Service) UninstallSoftwareTitle(ctx context.Context, _ uint, _ uint) error { + // skipauth: No authorization check needed due to implementation returning only license error. + svc.authz.SkipAuthorization(ctx) + return fleet.ErrMissingLicense +} + type getSoftwareInstallResultsRequest struct { InstallUUID string `url:"install_uuid"` } @@ -318,33 +540,70 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st //////////////////////////////////////////////////////////////////////////////// type batchSetSoftwareInstallersRequest struct { - TeamName string `json:"-" query:"team_name"` + TeamName string `json:"-" query:"team_name,optional"` DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes Software []fleet.SoftwareInstallerPayload `json:"software"` } type batchSetSoftwareInstallersResponse struct { - Err error `json:"error,omitempty"` + RequestUUID string `json:"request_uuid"` + Err error `json:"error,omitempty"` } func (r batchSetSoftwareInstallersResponse) error() error { return r.Err } -func (r batchSetSoftwareInstallersResponse) Status() int { return http.StatusNoContent } - func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*batchSetSoftwareInstallersRequest) - if err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun); err != nil { + requestUUID, err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun) + if err != nil { return batchSetSoftwareInstallersResponse{Err: err}, nil } - return batchSetSoftwareInstallersResponse{}, nil + return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil } -func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error { +func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) (string, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + return "", fleet.ErrMissingLicense +} + +type batchSetSoftwareInstallersResultRequest struct { + RequestUUID string `url:"request_uuid"` + TeamName string `query:"team_name,optional"` + DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not save changes +} + +type batchSetSoftwareInstallersResultResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Packages []fleet.SoftwarePackageResponse `json:"packages"` + + Err error `json:"error,omitempty"` +} + +func (r batchSetSoftwareInstallersResultResponse) error() error { return r.Err } + +func batchSetSoftwareInstallersResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*batchSetSoftwareInstallersResultRequest) + status, message, packages, err := svc.GetBatchSetSoftwareInstallersResult(ctx, req.TeamName, req.RequestUUID, req.DryRun) + if err != nil { + return batchSetSoftwareInstallersResultResponse{Err: err}, nil + } + return batchSetSoftwareInstallersResultResponse{ + Status: status, + Message: message, + Packages: packages, + }, nil +} + +func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return "", "", nil, fleet.ErrMissingLicense } ////////////////////////////////////////////////////////////////////////////// @@ -411,6 +670,17 @@ type batchAssociateAppStoreAppsRequest struct { Apps []fleet.VPPBatchPayload `json:"app_store_apps"` } +func (b *batchAssociateAppStoreAppsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error { + if err := json.NewDecoder(r).Decode(b); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return ctxerr.Wrap(ctx, fleet.NewUserMessageError(fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value), http.StatusBadRequest)) + } + } + + return nil +} + type batchAssociateAppStoreAppsResponse struct { Err error `json:"error,omitempty"` } diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 2dc965d139..722cecda7a 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -79,6 +79,20 @@ func TestSoftwareInstallersAuth(t *testing.T) { return nil } + tokenExpiration := time.Now().Add(24 * time.Hour) + token, err := test.CreateVPPTokenEncoded(tokenExpiration, "fleet", "ca") + require.NoError(t, err) + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ + ID: 1, + OrgName: "Fleet", + Location: "Earth", + RenewDate: tokenExpiration, + Token: string(token), + Teams: nil, + }, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } @@ -104,7 +118,7 @@ func TestSoftwareInstallersAuth(t *testing.T) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil } - _, err := svc.DownloadSoftwareInstaller(ctx, 1, tt.teamID) + _, err = svc.DownloadSoftwareInstaller(ctx, false, "media", 1, tt.teamID) if tt.teamID == nil { require.Error(t, err) } else { @@ -129,7 +143,7 @@ func TestSoftwareInstallersAuth(t *testing.T) { } } - err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}) + err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}}) if tt.teamID == nil { require.Error(t, err) } else { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 39f38177ae..91c5ce98e0 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -68,14 +68,17 @@ func (svc *Service) ListSoftwareTitles( return nil, 0, nil, err } - if opt.TeamID != nil && *opt.TeamID != 0 { - lic, err := svc.License(ctx) - if err != nil { - return nil, 0, nil, ctxerr.Wrap(ctx, err, "get license") - } - if !lic.IsPremium() { - return nil, 0, nil, fleet.ErrMissingLicense - } + lic, err := svc.License(ctx) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "get license") + } + + if opt.TeamID != nil && *opt.TeamID != 0 && !lic.IsPremium() { + return nil, 0, nil, fleet.ErrMissingLicense + } + + if !lic.IsPremium() && (opt.MaximumCVSS > 0 || opt.MinimumCVSS > 0 || opt.KnownExploit) { + return nil, 0, nil, fleet.ErrMissingLicense } // always include metadata for software titles diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 81ebee7d40..14cea750e1 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -29,6 +29,7 @@ type teamPolicyRequest struct { Platform string `json:"platform"` Critical bool `json:"critical" premium:"true"` CalendarEventsEnabled bool `json:"calendar_events_enabled"` + SoftwareTitleID *uint `json:"software_title_id"` } type teamPolicyResponse struct { @@ -40,7 +41,7 @@ func (r teamPolicyResponse) error() error { return r.Err } func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*teamPolicyRequest) - resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.PolicyPayload{ + resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{ QueryID: req.QueryID, Name: req.Name, Query: req.Query, @@ -49,6 +50,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv Platform: req.Platform, Critical: req.Critical, CalendarEventsEnabled: req.CalendarEventsEnabled, + SoftwareTitleID: req.SoftwareTitleID, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -56,7 +58,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv return teamPolicyResponse{Policy: resp}, nil } -func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.PolicyPayload) (*fleet.Policy, error) { +func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewTeamPolicyPayload) (*fleet.Policy, error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -70,6 +72,11 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, errors.New("user must be authenticated to create team policies") } + p, err := svc.newTeamPolicyPayloadToPolicyPayload(ctx, teamID, tp) + if err != nil { + return nil, err + } + if err := p.Verify(); err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: fmt.Sprintf("policy payload verification: %s", err), @@ -80,6 +87,10 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, ctxerr.Wrap(ctx, err, "creating policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -95,6 +106,39 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return policy, nil } +func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet.Policy) error { + if p.SoftwareInstallerID == nil { + return nil + } + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, *p.SoftwareInstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + p.InstallSoftware = &fleet.PolicySoftwareTitle{ + SoftwareTitleID: *installerMetadata.TitleID, + Name: installerMetadata.SoftwareTitle, + } + return nil +} + +func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (fleet.PolicyPayload, error) { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, &teamID, p.SoftwareTitleID) + if err != nil { + return fleet.PolicyPayload{}, err + } + return fleet.PolicyPayload{ + QueryID: p.QueryID, + Name: p.Name, + Query: p.Query, + Critical: p.Critical, + Description: p.Description, + Resolution: p.Resolution, + Platform: p.Platform, + CalendarEventsEnabled: p.CalendarEventsEnabled, + SoftwareInstallerID: softwareInstallerID, + }, nil +} + ///////////////////////////////////////////////////////////////////////////////// // List ///////////////////////////////////////////////////////////////////////////////// @@ -143,16 +187,34 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee return nil, nil, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return nil, nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if mergeInherited { - p, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) - return p, nil, err + policies, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) + for i := range policies { + if err := svc.populatePolicyInstallSoftware(ctx, policies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", policies[i].ID) + } + } + return policies, nil, err } - return svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + teamPolicies, inheritedPolicies, err = svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + if err != nil { + return nil, nil, err + } + + for i := range teamPolicies { + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", teamPolicies[i].ID) + } + } + + return teamPolicies, inheritedPolicies, nil } ///////////////////////////////////////////////////////////////////////////////// @@ -190,8 +252,10 @@ func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQue return 0, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return 0, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return 0, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if mergeInherited { @@ -240,6 +304,10 @@ func (svc Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, po return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + return teamPolicy, nil } @@ -277,8 +345,10 @@ func (svc Service) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []ui return nil, err } - if _, err := svc.ds.Team(ctx, teamID); err != nil { - return nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + if teamID > 0 { + if _, err := svc.ds.Team(ctx, teamID); err != nil { + return nil, ctxerr.Wrapf(ctx, err, "loading team %d", teamID) + } } if len(ids) == 0 { @@ -418,6 +488,21 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f policy.FailingHostCount = 0 policy.PassingHostCount = 0 } + if p.SoftwareTitleID != nil { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, teamID, p.SoftwareTitleID) + if err != nil { + return nil, err + } + // If the associated installer is changed (or it's set and the policy didn't have an associated installer) + // then we clear the results of the policy so that automation can be triggered upon failure + // (automation is currently triggered on the first failure or when it goes from passing to failure). + if softwareInstallerID != nil && (policy.SoftwareInstallerID == nil || *policy.SoftwareInstallerID != *softwareInstallerID) { + removeAllMemberships = true + removeStats = true + } + policy.SoftwareInstallerID = softwareInstallerID + } + logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) err = svc.ds.SavePolicy(ctx, policy, removeAllMemberships, removeStats) @@ -425,6 +510,10 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return nil, ctxerr.Wrap(ctx, err, "saving policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -440,3 +529,44 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return policy, nil } + +func (svc *Service) deduceSoftwareInstallerIDFromTitleID(ctx context.Context, teamID *uint, softwareTitleID *uint) (*uint, error) { + if softwareTitleID == nil { + return nil, nil + } + + // If *p.SoftwareTitleID with value 0 is used to unset the current installer from the policy. + if *softwareTitleID == 0 { + return nil, nil + } + + if teamID == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "software_title_id cannot be set on global policies", + }) + } + + softwareTitle, err := svc.SoftwareTitleByID(ctx, *softwareTitleID, teamID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d not found", *softwareTitleID, *teamID), + }) + } + return nil, ctxerr.Wrap(ctx, err, "software title by id") + } + if softwareTitle.AppStoreApp != nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d is assocated to a VPP app, only software installers are supported", *softwareTitleID, *teamID), + }) + } + if softwareTitle.SoftwarePackage == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d does not have associated package", *softwareTitleID, *teamID), + }) + } + + // At this point we assume *softwareTitle.SoftwarePackage.TeamID == *teamID, + // because SoftwareTitleByID above receives the teamID. + return ptr.Uint(softwareTitle.SoftwarePackage.InstallerID), nil +} diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index 5c6c25ff8c..551e6e567c 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -32,7 +32,7 @@ func TestTeamPoliciesAuth(t *testing.T) { return nil, nil } ds.TeamPolicyFunc = func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) { - return nil, nil + return &fleet.Policy{}, nil } ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) { if id == 1 { @@ -68,6 +68,9 @@ func TestTeamPoliciesAuth(t *testing.T) { ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { return &fleet.Team{ID: 1}, nil } + ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{}, nil + } testCases := []struct { name string @@ -149,7 +152,7 @@ func TestTeamPoliciesAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - _, err := svc.NewTeamPolicy(ctx, 1, fleet.PolicyPayload{ + _, err := svc.NewTeamPolicy(ctx, 1, fleet.NewTeamPolicyPayload{ Name: "query1", Query: "select 1;", }) diff --git a/server/service/teams.go b/server/service/teams.go index 1d8a30032d..d2bcf6a994 100644 --- a/server/service/teams.go +++ b/server/service/teams.go @@ -5,11 +5,12 @@ import ( "crypto/x509" "encoding/json" "fmt" - "golang.org/x/text/unicode/norm" "io" "net/http" "net/url" + "golang.org/x/text/unicode/norm" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) diff --git a/server/service/teams_test.go b/server/service/teams_test.go index 9011e8bce7..cf71aab795 100644 --- a/server/service/teams_test.go +++ b/server/service/teams_test.go @@ -53,8 +53,9 @@ func TestTeamAuth(t *testing.T) { ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { return []*fleet.Host{}, nil diff --git a/server/service/testdata/software-installers/README.md b/server/service/testdata/software-installers/README.md new file mode 100644 index 0000000000..b5a59d9daf --- /dev/null +++ b/server/service/testdata/software-installers/README.md @@ -0,0 +1,3 @@ +# testdata + +- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`. \ No newline at end of file diff --git a/server/service/testdata/software-installers/fleet-osquery.msi b/server/service/testdata/software-installers/fleet-osquery.msi new file mode 100644 index 0000000000..cc52ad5d4e Binary files /dev/null and b/server/service/testdata/software-installers/fleet-osquery.msi differ diff --git a/server/service/testdata/software-installers/no_version.pkg b/server/service/testdata/software-installers/no_version.pkg new file mode 100644 index 0000000000..c649ebf17b Binary files /dev/null and b/server/service/testdata/software-installers/no_version.pkg differ diff --git a/server/service/testing_client.go b/server/service/testing_client.go index b5232c2b1c..48476f7162 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -118,12 +118,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, ts.ds.DeleteHost(ctx, host.ID)) } - // clean up any software installers - mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM software_installers`) - return err - }) - lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{}) require.NoError(t, err) for _, lbl := range lbls { @@ -161,6 +155,12 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, err) } + // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) + return err + }) + globalPolicies, err := ts.ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) require.NoError(t, err) if len(globalPolicies) > 0 { @@ -198,6 +198,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { _, err := q.ExecContext(ctx, `DELETE FROM host_script_results`) return err }) + + mysql.ExecAdhocSQL(t, ts.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;") + return err + }) } func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response { @@ -279,6 +284,21 @@ func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStat } } +func (ts *withServer) DoJSONWithoutAuth(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) { + t := ts.s.T() + rawBytes, err := json.Marshal(params) + require.NoError(t, err) + resp := ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{}, queryParams...) + t.Cleanup(func() { + resp.Body.Close() + }) + err = json.NewDecoder(resp.Body).Decode(v) + require.NoError(ts.s.T(), err) + if e, ok := v.(errorer); ok { + require.NoError(ts.s.T(), e.error()) + } +} + func (ts *withServer) getTestAdminToken() string { testUser := testUsers["admin1"] @@ -480,3 +500,24 @@ func (ts *withServer) lastActivityOfTypeMatches(name, details string, id uint) u t.Fatalf("no activity of type %s found in the last %d activities", name, len(listActivities.Activities)) return 0 } + +func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id uint) { + t := ts.s.T() + + var listActivities listActivitiesResponse + ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, + &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10") + require.True(t, len(listActivities.Activities) > 0) + + for _, act := range listActivities.Activities { + if act.Type == name { + if details != "" { + require.NotNil(t, act.Details) + assert.NotEqual(t, details, string(*act.Details)) + } + if id > 0 { + assert.NotEqual(t, id, act.ID) + } + } + } +} diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index ce7a82ea9e..7e5937c56c 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -34,6 +34,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/async" "github.com/fleetdm/fleet/v4/server/service/mock" + "github.com/fleetdm/fleet/v4/server/service/redis_key_value" "github.com/fleetdm/fleet/v4/server/service/redis_lock" "github.com/fleetdm/fleet/v4/server/sso" "github.com/fleetdm/fleet/v4/server/test" @@ -64,13 +65,15 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }} c clock.Clock = clock.C - is fleet.InstallerStore - mdmStorage fleet.MDMAppleStore - mdmPusher nanomdm_push.Pusher - ssoStore sso.SessionStore - profMatcher fleet.ProfileMatcher - softwareInstallStore fleet.SoftwareInstallerStore - distributedLock fleet.Lock + is fleet.InstallerStore + mdmStorage fleet.MDMAppleStore + mdmPusher nanomdm_push.Pusher + ssoStore sso.SessionStore + profMatcher fleet.ProfileMatcher + softwareInstallStore fleet.SoftwareInstallerStore + bootstrapPackageStore fleet.MDMBootstrapPackageStore + distributedLock fleet.Lock + keyValueStore fleet.KeyValueStore ) if len(opts) > 0 { if opts[0].Clock != nil { @@ -78,6 +81,10 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf } } + if len(opts) > 0 && opts[0].KeyValueStore != nil { + keyValueStore = opts[0].KeyValueStore + } + task := async.NewTask(ds, nil, c, config.OsqueryConfig{}) if len(opts) > 0 { if opts[0].Task != nil { @@ -98,6 +105,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf ssoStore = sso.NewSessionStore(opts[0].Pool) profMatcher = apple_mdm.NewProfileMatcher(opts[0].Pool) distributedLock = redis_lock.NewLock(opts[0].Pool) + keyValueStore = redis_key_value.New(opts[0].Pool) } if opts[0].ProfileMatcher != nil { profMatcher = opts[0].ProfileMatcher @@ -115,6 +123,9 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf if opts[0].SoftwareInstallStore != nil { softwareInstallStore = opts[0].SoftwareInstallStore } + if opts[0].BootstrapPackageStore != nil { + bootstrapPackageStore = opts[0].BootstrapPackageStore + } // allow to explicitly set installer store to nil is = opts[0].Is @@ -197,7 +208,9 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf ssoStore, profMatcher, softwareInstallStore, + bootstrapPackageStore, distributedLock, + keyValueStore, ) if err != nil { panic(err) @@ -287,30 +300,32 @@ func (svc *mockMailService) SendEmail(e fleet.Email) error { type TestNewScheduleFunc func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc type TestServerOpts struct { - Logger kitlog.Logger - License *fleet.LicenseInfo - SkipCreateTestUsers bool - Rs fleet.QueryResultStore - Lq fleet.LiveQueryStore - Pool fleet.RedisPool - FailingPolicySet fleet.FailingPolicySet - Clock clock.Clock - Task *async.Task - EnrollHostLimiter fleet.EnrollHostLimiter - Is fleet.InstallerStore - FleetConfig *config.FleetConfig - MDMStorage fleet.MDMAppleStore - DEPStorage nanodep_storage.AllDEPStorage - SCEPStorage scep_depot.Depot - MDMPusher nanomdm_push.Pusher - HTTPServerConfig *http.Server - StartCronSchedules []TestNewScheduleFunc - UseMailService bool - APNSTopic string - ProfileMatcher fleet.ProfileMatcher - EnableCachedDS bool - NoCacheDatastore bool - SoftwareInstallStore fleet.SoftwareInstallerStore + Logger kitlog.Logger + License *fleet.LicenseInfo + SkipCreateTestUsers bool + Rs fleet.QueryResultStore + Lq fleet.LiveQueryStore + Pool fleet.RedisPool + FailingPolicySet fleet.FailingPolicySet + Clock clock.Clock + Task *async.Task + EnrollHostLimiter fleet.EnrollHostLimiter + Is fleet.InstallerStore + FleetConfig *config.FleetConfig + MDMStorage fleet.MDMAppleStore + DEPStorage nanodep_storage.AllDEPStorage + SCEPStorage scep_depot.Depot + MDMPusher nanomdm_push.Pusher + HTTPServerConfig *http.Server + StartCronSchedules []TestNewScheduleFunc + UseMailService bool + APNSTopic string + ProfileMatcher fleet.ProfileMatcher + EnableCachedDS bool + NoCacheDatastore bool + SoftwareInstallStore fleet.SoftwareInstallerStore + BootstrapPackageStore fleet.MDMBootstrapPackageStore + KeyValueStore fleet.KeyValueStore } func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServerOpts) (map[string]fleet.User, *httptest.Server) { @@ -373,6 +388,10 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ server := httptest.NewUnstartedServer(rootMux) server.Config = cfg.Server.DefaultHTTPServer(ctx, rootMux) + // WriteTimeout is set for security purposes. + // If we don't set it, (bugy or malignant) clients making long running + // requests could DDOS Fleet. + require.NotZero(t, server.Config.WriteTimeout) if len(opts) > 0 && opts[0].HTTPServerConfig != nil { server.Config = opts[0].HTTPServerConfig // make sure we use the application handler we just created @@ -626,7 +645,6 @@ func mdmConfigurationRequiredEndpoints() []struct { {"DELETE", "/api/latest/fleet/mdm/apple/installers/1", false, false}, {"GET", "/api/latest/fleet/mdm/apple/installers", false, false}, {"GET", "/api/latest/fleet/mdm/apple/devices", false, false}, - {"GET", "/api/latest/fleet/mdm/apple/dep/devices", false, false}, {"GET", "/api/latest/fleet/mdm/apple/profiles", false, false}, {"GET", "/api/latest/fleet/mdm/apple/profiles/1", false, false}, {"DELETE", "/api/latest/fleet/mdm/apple/profiles/1", false, false}, diff --git a/server/service/transport.go b/server/service/transport.go index 4e9d9df562..145fa78c1f 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -87,6 +87,19 @@ func uintFromRequest(r *http.Request, name string) (uint64, error) { return u, nil } +func uint32FromRequest(r *http.Request, name string) (uint32, error) { + vars := mux.Vars(r) + s, ok := vars[name] + if !ok { + return 0, errBadRoute + } + u, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, ctxerr.Wrap(r.Context(), err, "uint32FromRequest") + } + return uint32(u), nil +} + func intFromRequest(r *http.Request, name string) (int64, error) { vars := mux.Vars(r) s, ok := vars[name] diff --git a/server/service/vpp.go b/server/service/vpp.go index 4f2827edbb..c2e25eddc0 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -2,7 +2,11 @@ package service import ( "context" + "io" + "mime/multipart" + "net/http" + "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -44,9 +48,10 @@ func (svc *Service) GetAppStoreApps(ctx context.Context, teamID *uint) ([]*fleet ////////////////////////////////////////////////////////////////////////////// type addAppStoreAppRequest struct { - TeamID *uint `json:"team_id"` - AppStoreID string `json:"app_store_id"` - Platform fleet.AppleDevicePlatform `json:"platform"` + TeamID *uint `json:"team_id"` + AppStoreID string `json:"app_store_id"` + Platform fleet.AppleDevicePlatform `json:"platform"` + SelfService bool `json:"self_service"` } type addAppStoreAppResponse struct { @@ -57,7 +62,7 @@ func (r addAppStoreAppResponse) error() error { return r.Err } func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*addAppStoreAppRequest) - err := svc.AddAppStoreApp(ctx, req.TeamID, fleet.VPPAppID{AdamID: req.AppStoreID, Platform: req.Platform}) + err := svc.AddAppStoreApp(ctx, req.TeamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: req.AppStoreID, Platform: req.Platform}, SelfService: req.SelfService}) if err != nil { return &addAppStoreAppResponse{Err: err}, nil } @@ -65,7 +70,243 @@ func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet. return &addAppStoreAppResponse{}, nil } -func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppID) error { +func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppTeam) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////////////////////////////////// +// POST /api/_version_/vpp_tokens +//////////////////////////////////////////////////////////////////////////////// + +type uploadVPPTokenRequest struct { + File *multipart.FileHeader +} + +func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := uploadVPPTokenRequest{} + + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "token multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["token"][0] + + return &decoded, nil +} + +type uploadVPPTokenResponse struct { + Err error `json:"error,omitempty"` + Token *fleet.VPPTokenDB `json:"token,omitempty"` +} + +func (r uploadVPPTokenResponse) Status() int { return http.StatusAccepted } + +func (r uploadVPPTokenResponse) error() error { + return r.Err +} + +func uploadVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*uploadVPPTokenRequest) + file, err := req.File.Open() + if err != nil { + return uploadVPPTokenResponse{Err: err}, nil + } + defer file.Close() + + tok, err := svc.UploadVPPToken(ctx, file) + if err != nil { + return uploadVPPTokenResponse{Err: err}, nil + } + + return uploadVPPTokenResponse{Token: tok}, nil +} + +func (svc *Service) UploadVPPToken(ctx context.Context, file io.ReadSeeker) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////// +// PATCH /api/_version_/fleet/vpp_tokens/%d/renew // +//////////////////////////////////////////////////// + +type patchVPPTokenRenewRequest struct { + ID uint `url:"id"` + File *multipart.FileHeader +} + +func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := patchVPPTokenRenewRequest{} + + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "token multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["token"][0] + + return &decoded, nil +} + +type patchVPPTokenRenewResponse struct { + Err error `json:"error,omitempty"` + Token *fleet.VPPTokenDB `json:"token,omitempty"` +} + +func (r patchVPPTokenRenewResponse) Status() int { return http.StatusAccepted } + +func (r patchVPPTokenRenewResponse) error() error { + return r.Err +} + +func patchVPPTokenRenewEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*patchVPPTokenRenewRequest) + file, err := req.File.Open() + if err != nil { + return patchVPPTokenRenewResponse{Err: err}, nil + } + defer file.Close() + + tok, err := svc.UpdateVPPToken(ctx, req.ID, file) + if err != nil { + return patchVPPTokenRenewResponse{Err: err}, nil + } + + return patchVPPTokenRenewResponse{Token: tok}, nil +} + +func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////// +// PATCH /api/_version_/fleet/vpp_tokens/%d/teams // +//////////////////////////////////////////////////// + +type patchVPPTokensTeamsRequest struct { + ID uint `url:"id"` + TeamIDs []uint `json:"teams"` +} + +type patchVPPTokensTeamsResponse struct { + Token *fleet.VPPTokenDB `json:"token,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r patchVPPTokensTeamsResponse) error() error { return r.Err } + +func patchVPPTokensTeams(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*patchVPPTokensTeamsRequest) + + tok, err := svc.UpdateVPPTokenTeams(ctx, req.ID, req.TeamIDs) + if err != nil { + return patchVPPTokensTeamsResponse{Err: err}, nil + } + return patchVPPTokensTeamsResponse{Token: tok}, nil +} + +func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +///////////////////////////////////////// +// GET /api/_version_/fleet/vpp_tokens // +///////////////////////////////////////// + +type getVPPTokensRequest struct{} + +type getVPPTokensResponse struct { + Tokens []*fleet.VPPTokenDB `json:"vpp_tokens"` + Err error `json:"error,omitempty"` +} + +func (r getVPPTokensResponse) error() error { return r.Err } + +func getVPPTokens(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + tokens, err := svc.GetVPPTokens(ctx) + if err != nil { + return getVPPTokensResponse{Err: err}, nil + } + + if tokens == nil { + tokens = []*fleet.VPPTokenDB{} + } + + return getVPPTokensResponse{Tokens: tokens}, nil +} + +func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +/////////////////////////////////////////////// +// DELETE /api/_version_/fleet/vpp_tokens/%d // +/////////////////////////////////////////////// + +type deleteVPPTokenRequest struct { + ID uint `url:"id"` +} + +type deleteVPPTokenResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteVPPTokenResponse) error() error { return r.Err } + +func (r deleteVPPTokenResponse) Status() int { return http.StatusNoContent } + +func deleteVPPToken(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*deleteVPPTokenRequest) + + err := svc.DeleteVPPToken(ctx, req.ID) + if err != nil { + return deleteVPPTokenResponse{Err: err}, nil + } + + return deleteVPPTokenResponse{}, nil +} + +func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) diff --git a/server/service/vpp_test.go b/server/service/vpp_test.go index 4ad375636e..95b9c65ed0 100644 --- a/server/service/vpp_test.go +++ b/server/service/vpp_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -20,6 +21,18 @@ func TestVPPAuth(t *testing.T) { svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + // use a custom implementation of checkAuthErr as the service call will fail + // with a different error for in case of authorization success and the + // package-wide checkAuthErr requires no error. + checkAuthErr := func(t *testing.T, shouldFail bool, err error) { + if shouldFail { + require.Error(t, err) + require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) + } else if err != nil { + require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error()) + } + } + testCases := []struct { name string user *fleet.User @@ -63,14 +76,15 @@ func TestVPPAuth(t *testing.T) { ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) { return false, nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil } - ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { return &fleet.Team{ID: 1}, nil } + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ID: 1, OrgName: "org", Teams: []fleet.TeamTuple{{ID: 1}}}, nil + } // Note: these calls always return an error because they're attempting to unmarshal a // non-existent VPP token. @@ -78,18 +92,14 @@ func TestVPPAuth(t *testing.T) { if tt.teamID == nil { require.Error(t, err) } else { - if tt.shouldFailRead { - checkAuthErr(t, true, err) - } + checkAuthErr(t, tt.shouldFailRead, err) } - err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}) + err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}}) if tt.teamID == nil { require.Error(t, err) } else { - if tt.shouldFailWrite { - checkAuthErr(t, true, err) - } + checkAuthErr(t, tt.shouldFailWrite, err) } }) } diff --git a/server/service/vulnerabilities.go b/server/service/vulnerabilities.go index 62cfd3dcfe..cf301c77c9 100644 --- a/server/service/vulnerabilities.go +++ b/server/service/vulnerabilities.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "net/http" + "regexp" "time" "github.com/fleetdm/fleet/v4/server/authz" @@ -17,6 +19,18 @@ var freeValidVulnSortColumns = []string{ "created_at", } +type cveNotFoundError struct{} + +var _ fleet.NotFoundError = (*cveNotFoundError)(nil) + +func (p cveNotFoundError) Error() string { + return "This is not a known CVE. None of Fleet’s vulnerability sources are aware of this CVE." +} + +func (p cveNotFoundError) IsNotFound() bool { + return true +} + type listVulnerabilitiesRequest struct { fleet.VulnListOptions } @@ -29,6 +43,9 @@ type listVulnerabilitiesResponse struct { Err error `json:"error,omitempty"` } +// Allow formats like: CVE-2017-12345, cve-2017-12345 +var cveRegex = regexp.MustCompile(`(?i)^CVE-\d{4}-\d{4}\d*$`) + func (r listVulnerabilitiesResponse) error() error { return r.Err } func listVulnerabilitiesEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (errorer, error) { @@ -99,6 +116,10 @@ func (svc *Service) CountVulnerabilities(ctx context.Context, opts fleet.VulnLis return svc.ds.CountVulnerabilities(ctx, opts) } +func (svc *Service) IsCVEKnownToFleet(ctx context.Context, cve string) (bool, error) { + return svc.ds.IsCVEKnownToFleet(ctx, cve) +} + type getVulnerabilityRequest struct { CVE string `url:"cve"` TeamID *uint `query:"team_id,optional"` @@ -109,17 +130,29 @@ type getVulnerabilityResponse struct { OSVersions []*fleet.VulnerableOS `json:"os_versions"` Software []*fleet.VulnerableSoftware `json:"software"` Err error `json:"error,omitempty"` + statusCode int } func (r getVulnerabilityResponse) error() error { return r.Err } +func (r getVulnerabilityResponse) Status() int { + if r.statusCode == 0 { + return http.StatusOK + } + return r.statusCode +} + func getVulnerabilityEndpoint(ctx context.Context, req interface{}, svc fleet.Service) (errorer, error) { request := req.(*getVulnerabilityRequest) - vuln, err := svc.Vulnerability(ctx, request.CVE, request.TeamID, false) + vuln, known, err := svc.Vulnerability(ctx, request.CVE, request.TeamID, false) if err != nil { return getVulnerabilityResponse{Err: err}, nil } + if vuln == nil && known { + // Return 204 status code if the vulnerability is known to Fleet but does not match any host software/OS + return getVulnerabilityResponse{statusCode: http.StatusNoContent}, nil + } vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE.CVE) @@ -140,30 +173,48 @@ func getVulnerabilityEndpoint(ctx context.Context, req interface{}, svc fleet.Se }, nil } -func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (*fleet.VulnerabilityWithMetadata, error) { +func (svc *Service) Vulnerability(ctx context.Context, cve string, teamID *uint, useCVSScores bool) (vuln *fleet.VulnerabilityWithMetadata, + known bool, err error, +) { if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err } if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionRead); err != nil { - return nil, err + return nil, false, err + } + + if !cveRegex.Match([]byte(cve)) { + return nil, false, badRequest("That vulnerability (CVE) is not valid. Try updating your search to use CVE format: \"CVE-YYYY-<4 or more digits>\"") } if teamID != nil && *teamID != 0 { exists, err := svc.ds.TeamExists(ctx, *teamID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "checking if team exists") + return nil, false, ctxerr.Wrap(ctx, err, "checking if team exists") } else if !exists { - return nil, authz.ForbiddenWithInternal("team does not exist", nil, nil, nil) + return nil, false, authz.ForbiddenWithInternal("team does not exist", nil, nil, nil) } } - vuln, err := svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores) - if err != nil { - return nil, err + vuln, err = svc.ds.Vulnerability(ctx, cve, teamID, useCVSScores) + switch { + case fleet.IsNotFound(err): + var errKnown error + known, errKnown = svc.ds.IsCVEKnownToFleet(ctx, cve) + if errKnown != nil { + return nil, false, errKnown + } + if !known { + return nil, false, cveNotFoundError{} + } + case err != nil: + return nil, false, err + default: + known = true } - return vuln, nil + return vuln, known, nil } func (svc *Service) ListOSVersionsByCVE(ctx context.Context, cve string, teamID *uint) (result []*fleet.VulnerableOS, updatedAt time.Time, err error) { diff --git a/server/service/vulnerabilities_test.go b/server/service/vulnerabilities_test.go index 48b6303d98..fcef3ede9a 100644 --- a/server/service/vulnerabilities_test.go +++ b/server/service/vulnerabilities_test.go @@ -173,10 +173,10 @@ func TestVulnerabilitesAuth(t *testing.T) { }) checkAuthErr(t, tc.shouldFailTeamRead, err) - _, err = svc.Vulnerability(ctx, "CVE-2019-1234", nil, false) + _, _, err = svc.Vulnerability(ctx, "CVE-2019-1234", nil, false) checkAuthErr(t, tc.shouldFailGlobalRead, err) - _, err = svc.Vulnerability(ctx, "CVE-2019-1234", ptr.Uint(1), false) + _, _, err = svc.Vulnerability(ctx, "CVE-2019-1234", ptr.Uint(1), false) checkAuthErr(t, tc.shouldFailTeamRead, err) }) } diff --git a/server/shellquote/shellquote.go b/server/shellquote/shellquote.go new file mode 100644 index 0000000000..8960a427c3 --- /dev/null +++ b/server/shellquote/shellquote.go @@ -0,0 +1,156 @@ +// Based on https://github.com/kballard/go-shellquote + +package shellquote + +import ( + "bytes" + "errors" + "strings" + "unicode/utf8" +) + +var ( + UnterminatedSingleQuoteError = errors.New("unterminated single-quoted string") + UnterminatedDoubleQuoteError = errors.New("unterminated double-quoted string") + UnterminatedEscapeError = errors.New("unterminated backslash-escape") +) + +var ( + splitChars = " \n\t" + singleChar = '\'' + doubleChar = '"' + escapeChar = '\\' + doubleEscapeChars = "$`\"\n\\" +) + +// Split splits a string according to /bin/sh's word-splitting rules. It +// supports backslash-escapes, single-quotes, and double-quotes. Notably it does +// not support the $” style of quoting. It also doesn't attempt to perform any +// other sort of expansion, including brace expansion, shell expansion, or +// pathname expansion. +// +// If the given input has an unterminated quoted string or ends in a +// backslash-escape, one of UnterminatedSingleQuoteError, +// UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. +func Split(input string) (words []string, err error) { + var buf bytes.Buffer + words = make([]string, 0) + + for len(input) > 0 { + // skip any splitChars at the start + c, l := utf8.DecodeRuneInString(input) + if strings.ContainsRune(splitChars, c) { + input = input[l:] + continue + } else if c == escapeChar { + // Look ahead for escaped newline, so we can skip over it + next := input[l:] + if len(next) == 0 { + err = UnterminatedEscapeError + return + } + c2, l2 := utf8.DecodeRuneInString(next) + if c2 == '\n' { + input = next[l2:] + continue + } + } + + var word string + word, input, err = splitWord(input, &buf) + if err != nil { + return + } + words = append(words, word) + } + return +} + +func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { + buf.Reset() + +raw: + { + cur := input + for len(cur) > 0 { + c, l := utf8.DecodeRuneInString(cur) + cur = cur[l:] + if c == singleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto single + } else if c == doubleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto double + } else if c == escapeChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto escape + } else if strings.ContainsRune(splitChars, c) { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + return buf.String(), cur, nil + } + } + if len(input) > 0 { + buf.WriteString(input) + input = "" + } + goto done + } + +escape: + { + if len(input) == 0 { + return "", "", UnterminatedEscapeError + } + c, l := utf8.DecodeRuneInString(input) + // a backslash-escaped newline is elided from the output entirely + if c != '\n' { + buf.WriteString(input[:l]) + } + input = input[l:] + } + goto raw + +single: + { + i := strings.IndexRune(input, singleChar) + if i == -1 { + return "", "", UnterminatedSingleQuoteError + } + buf.WriteString(input[0:i]) + input = input[i+1:] + goto raw + } + +double: + { + cur := input + for len(cur) > 0 { + c, l := utf8.DecodeRuneInString(cur) + cur = cur[l:] + if c == doubleChar { + buf.WriteString(input[0 : len(input)-len(cur)-l]) + input = cur + goto raw + } else if c == escapeChar { + // bash only supports certain escapes in double-quoted strings + c2, l2 := utf8.DecodeRuneInString(cur) + cur = cur[l2:] + if strings.ContainsRune(doubleEscapeChars, c2) { + buf.WriteString(input[0 : len(input)-len(cur)-l-l2]) + // newline is special, skip the backslash entirely + if c2 != '\n' { + buf.WriteRune(c2) + } + input = cur + } + } + } + return "", "", UnterminatedDoubleQuoteError + } + +done: + return buf.String(), input, nil +} diff --git a/server/shellquote/shellquote_test.go b/server/shellquote/shellquote_test.go new file mode 100644 index 0000000000..0c59a48fd7 --- /dev/null +++ b/server/shellquote/shellquote_test.go @@ -0,0 +1,61 @@ +// Based on https://github.com/kballard/go-shellquote + +package shellquote + +import ( + "errors" + "reflect" + "testing" +) + +func TestSimpleSplit(t *testing.T) { + t.Parallel() + for _, elem := range simpleSplitTest { + output, err := Split(elem.input) + if err != nil { + t.Errorf("Input %q, got error %#v", elem.input, err) + } else if !reflect.DeepEqual(output, elem.output) { + t.Errorf("Input %q, got %q, expected %q", elem.input, output, elem.output) + } + } +} + +func TestErrorSplit(t *testing.T) { + t.Parallel() + for _, elem := range errorSplitTest { + _, err := Split(elem.input) + if !errors.Is(err, elem.error) { + t.Errorf("Input %q, got error %#v, expected error %#v", elem.input, err, elem.error) + } + } +} + +var simpleSplitTest = []struct { + input string + output []string +}{ + {"hello", []string{"hello"}}, + {"hello goodbye", []string{"hello", "goodbye"}}, + {"hello goodbye", []string{"hello", "goodbye"}}, + {"glob* test?", []string{"glob*", "test?"}}, + {"don\\'t you know the dewey decimal system\\?", []string{"don't", "you", "know", "the", "dewey", "decimal", "system?"}}, + {"'don'\\''t you know the dewey decimal system?'", []string{"don't you know the dewey decimal system?"}}, + {"one '' two", []string{"one", "", "two"}}, + {"text with\\\na backslash-escaped newline", []string{"text", "witha", "backslash-escaped", "newline"}}, + {"text \"with\na\" quoted newline", []string{"text", "with\na", "quoted", "newline"}}, + {"\"quoted\\d\\\\\\\" text with\\\na backslash-escaped newline\"", []string{"quoted\\d\\\" text witha backslash-escaped newline"}}, + {"text with an escaped \\\n newline in the middle", []string{"text", "with", "an", "escaped", "newline", "in", "the", "middle"}}, + {"foo\"bar\"baz", []string{"foobarbaz"}}, + {"--foo 6mI74pVBAidu1bALjY0F+wN4mPQyu8DUap/9M/kHp8I=", []string{"--foo", "6mI74pVBAidu1bALjY0F+wN4mPQyu8DUap/9M/kHp8I="}}, +} + +var errorSplitTest = []struct { + input string + error error +}{ + {"don't worry", UnterminatedSingleQuoteError}, + {"'test'\\''ing", UnterminatedSingleQuoteError}, + {"\"foo'bar", UnterminatedDoubleQuoteError}, + {"foo\\", UnterminatedEscapeError}, + {" \\", UnterminatedEscapeError}, +} diff --git a/server/test/mdm.go b/server/test/mdm.go new file mode 100644 index 0000000000..0b5e8a5760 --- /dev/null +++ b/server/test/mdm.go @@ -0,0 +1,69 @@ +package test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" + "github.com/tj/assert" +) + +func CreateVPPTokenData(expiration time.Time, orgName, location string) (*fleet.VPPTokenData, error) { + var randBytes [32]byte + _, err := rand.Read(randBytes[:]) + if err != nil { + return nil, fmt.Errorf("generating random bytes: %w", err) + } + token := base64.StdEncoding.EncodeToString(randBytes[:]) + raw := fleet.VPPTokenRaw{ + OrgName: orgName, + Token: token, + ExpDate: expiration.Format("2006-01-02T15:04:05Z0700"), + } + rawJson, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("marshalling vpp raw token: %w", err) + } + + base64Token := base64.StdEncoding.EncodeToString(rawJson) + return &fleet.VPPTokenData{Token: base64Token, Location: location}, nil +} + +func CreateInsertGlobalVPPToken(t *testing.T, ds fleet.Datastore) *fleet.VPPTokenDB { + ctx := context.Background() + dataToken, err := CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + tok1New, err := ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + + return tok1New +} + +func CreateVPPTokenEncoded(expiration time.Time, orgName, location string) ([]byte, error) { + dataToken, err := CreateVPPTokenData(expiration, orgName, location) + if err != nil { + return nil, err + } + return []byte(dataToken.Token), nil +} + +func CreateVPPTokenEncodedAfterMigration(expiration time.Time, orgName, location string) ([]byte, error) { + dataToken, err := CreateVPPTokenData(expiration, orgName, location) + if err != nil { + return nil, err + } + + dataTokenJson, err := json.Marshal(dataToken) + if err != nil { + return nil, fmt.Errorf("marshalling vpp data token: %w", err) + } + return dataTokenJson, nil +} diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 099b7a0e57..f8a2de578c 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -210,6 +210,12 @@ func WithPlatform(s string) NewHostOption { } } +func WithTeamID(teamID uint) NewHostOption { + return func(h *fleet.Host) { + h.TeamID = &teamID + } +} + func NewHost(tb testing.TB, ds fleet.Datastore, name, ip, key, uuid string, now time.Time, options ...NewHostOption) *fleet.Host { osqueryHostID, _ := server.GenerateRandomText(10) h := &fleet.Host{ diff --git a/server/vulnerabilities/customcve/matching_rules.go b/server/vulnerabilities/customcve/matching_rules.go index d4bc2d255c..88a0dc7bbd 100644 --- a/server/vulnerabilities/customcve/matching_rules.go +++ b/server/vulnerabilities/customcve/matching_rules.go @@ -43,6 +43,30 @@ func getCVEMatchingRules() CVEMatchingRules { CVEs: []string{"CVE-2024-30101", "CVE-2024-30102", "CVE-2024-30103", "CVE-2024-30104"}, ResolvedInVersion: "16.0.17628.20144", }, + // July 9 2024 Office 365 Vulnerabilities + // https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates + { + NameLikeMatch: "Microsoft 365", + SourceMatch: "programs", + CVEs: []string{"CVE-2023-38545", "CVE-2024-38020", "CVE-2024-38021"}, + ResolvedInVersion: "16.0.17726.20160", + }, + // August 13 2024 Office 365 Vulnerabilities + // https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates + { + NameLikeMatch: "Microsoft 365", + SourceMatch: "programs", + CVEs: []string{ + "CVE-2024-38172", + "CVE-2024-38170", + "CVE-2024-38173", + "CVE-2024-38171", + "CVE-2024-38189", + "CVE-2024-38169", + "CVE-2024-38200", + }, + ResolvedInVersion: "16.0.17830.20166", + }, } } diff --git a/server/vulnerabilities/customcve/matching_rules_test.go b/server/vulnerabilities/customcve/matching_rules_test.go index ee8eec9dc8..042ba68ef0 100644 --- a/server/vulnerabilities/customcve/matching_rules_test.go +++ b/server/vulnerabilities/customcve/matching_rules_test.go @@ -2,6 +2,7 @@ package customcve import ( "context" + "sort" "testing" "time" @@ -193,22 +194,32 @@ func TestValidateAll(t *testing.T) { func TestCheckCustomVulnerabilities(t *testing.T) { ds := new(mock.Store) sw := []fleet.Software{ + // Very old version should match all custom matching rules. { ID: 1, Name: "Microsoft 365 - en-us", Version: "16.0.17531.20152", Source: "programs", }, + // This version should match June matching rules but not July and August. { ID: 2, Name: "Microsoft 365 - en-us", - Version: "16.0.17425.20176", + Version: "16.0.17628.20144", Source: "programs", }, + // This version should match June and July, but not August. { ID: 3, Name: "Microsoft 365 - en-us", - Version: "16.0.17628.20144", + Version: "16.0.17726.20161", + Source: "programs", + }, + // This version should have no CVEs. + { + ID: 4, + Name: "Microsoft 365 - en-us", + Version: "16.0.17830.20167", Source: "programs", }, } @@ -233,8 +244,8 @@ func TestCheckCustomVulnerabilities(t *testing.T) { ctx := context.Background() vulns, err := CheckCustomVulnerabilities(ctx, ds, log.NewNopLogger(), 1*time.Hour) require.NoError(t, err) - require.Equal(t, 8, insertCount) - require.Len(t, vulns, 8) + require.Equal(t, 31, insertCount) + require.Len(t, vulns, 31) require.True(t, ds.DeleteOutOfDateVulnerabilitiesFuncInvoked) expected := []fleet.SoftwareVulnerability{ @@ -259,27 +270,155 @@ func TestCheckCustomVulnerabilities(t *testing.T) { ResolvedInVersion: ptr.String("16.0.17628.20144"), }, { - SoftwareID: 2, - CVE: "CVE-2024-30101", - ResolvedInVersion: ptr.String("16.0.17628.20144"), + SoftwareID: 1, + CVE: "CVE-2023-38545", + ResolvedInVersion: ptr.String("16.0.17726.20160"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38020", + ResolvedInVersion: ptr.String("16.0.17726.20160"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38021", + ResolvedInVersion: ptr.String("16.0.17726.20160"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38172", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38170", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38173", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38171", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38189", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38169", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 1, + CVE: "CVE-2024-38200", + ResolvedInVersion: ptr.String("16.0.17830.20166"), }, { SoftwareID: 2, - CVE: "CVE-2024-30102", - ResolvedInVersion: ptr.String("16.0.17628.20144"), + CVE: "CVE-2023-38545", + ResolvedInVersion: ptr.String("16.0.17726.20160"), }, { SoftwareID: 2, - CVE: "CVE-2024-30103", - ResolvedInVersion: ptr.String("16.0.17628.20144"), + CVE: "CVE-2024-38020", + ResolvedInVersion: ptr.String("16.0.17726.20160"), }, { SoftwareID: 2, - CVE: "CVE-2024-30104", - ResolvedInVersion: ptr.String("16.0.17628.20144"), + CVE: "CVE-2024-38021", + ResolvedInVersion: ptr.String("16.0.17726.20160"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38172", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38170", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38173", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38171", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38189", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38169", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 2, + CVE: "CVE-2024-38200", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38172", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38170", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38173", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38171", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38189", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38169", + ResolvedInVersion: ptr.String("16.0.17830.20166"), + }, + { + SoftwareID: 3, + CVE: "CVE-2024-38200", + ResolvedInVersion: ptr.String("16.0.17830.20166"), }, } + cmpSoftwareVulnerability := func(v []fleet.SoftwareVulnerability) func(i, j int) bool { + return func(i, j int) bool { + if v[i].SoftwareID <= v[j].SoftwareID { + if v[i].SoftwareID == v[j].SoftwareID { + return v[i].CVE < v[j].CVE + } + return true + } + return false + } + } + sort.Slice(expected, cmpSoftwareVulnerability(expected)) + sort.Slice(vulns, cmpSoftwareVulnerability(vulns)) require.Equal(t, expected, vulns) }) @@ -306,7 +445,7 @@ func TestCheckCustomVulnerabilities(t *testing.T) { vulns, err := CheckCustomVulnerabilities(ctx, ds, log.NewNopLogger(), 1*time.Hour) require.NoError(t, err) require.True(t, ds.DeleteOutOfDateVulnerabilitiesFuncInvoked) - require.Equal(t, 8, insertCount) + require.Equal(t, 31, insertCount) require.Len(t, vulns, 0) }) } diff --git a/server/vulnerabilities/goval_dictionary/analyzer.go b/server/vulnerabilities/goval_dictionary/analyzer.go new file mode 100644 index 0000000000..d4b6414e53 --- /dev/null +++ b/server/vulnerabilities/goval_dictionary/analyzer.go @@ -0,0 +1,139 @@ +package goval_dictionary + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/oval" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/utils" + kitlog "github.com/go-kit/log" +) + +const ( + hostsBatchSize = 500 + vulnBatchSize = 500 +) + +var ErrUnsupportedPlatform = errors.New("unsupported platform") + +// Analyze scans all hosts for vulnerabilities based on the sqlite output of goval-dictionary +// for their platform, inserting any new vulnerabilities and deleting anything patched. +// Returns nil, nil when the platform isn't supported. +func Analyze( + ctx context.Context, + ds fleet.Datastore, + ver fleet.OSVersion, + vulnPath string, + collectVulns bool, + logger kitlog.Logger, +) ([]fleet.SoftwareVulnerability, error) { + platform := oval.NewPlatform(ver.Platform, ver.Name) + source := fleet.GovalDictionarySource + if !platform.IsGovalDictionarySupported() { + return nil, ErrUnsupportedPlatform + } + db, err := loadDb(platform, vulnPath) + if err != nil { + return nil, err + } + + // Since hosts and software have a M:N relationship, the following sets are used to + // avoid doing duplicated inserts/delete operations (a vulnerable software might be + // present in many hosts). + toInsertSet := make(map[string]fleet.SoftwareVulnerability) + toDeleteSet := make(map[string]fleet.SoftwareVulnerability) + + var offset int + for { + hostIDs, err := ds.HostIDsByOSVersion(ctx, ver, offset, hostsBatchSize) + if err != nil { + return nil, err + } + + if len(hostIDs) == 0 { + break + } + offset += hostsBatchSize + + foundInBatch := make(map[uint][]fleet.SoftwareVulnerability) + for _, hostID := range hostIDs { + hostID := hostID + software, err := ds.ListSoftwareForVulnDetection(ctx, fleet.VulnSoftwareFilter{HostID: &hostID}) + if err != nil { + return nil, err + } + + vulnerabilities := db.Eval(software, logger) + foundInBatch[hostID] = vulnerabilities + } + + existingInBatch, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, hostIDs, source) + if err != nil { + return nil, err + } + + for _, hostID := range hostIDs { + inserts, deletes := utils.VulnsDelta(foundInBatch[hostID], existingInBatch[hostID]) + for _, i := range inserts { + toInsertSet[i.Key()] = i + } + for _, d := range deletes { + toDeleteSet[d.Key()] = d + } + } + } + + err = utils.BatchProcess(toDeleteSet, func(v []fleet.SoftwareVulnerability) error { + return ds.DeleteSoftwareVulnerabilities(ctx, v) + }, vulnBatchSize) + if err != nil { + return nil, err + } + + var inserted []fleet.SoftwareVulnerability + if collectVulns { + inserted = make([]fleet.SoftwareVulnerability, 0, len(toInsertSet)) + } + + err = utils.BatchProcess(toInsertSet, func(vulns []fleet.SoftwareVulnerability) error { + for _, v := range vulns { + ok, err := ds.InsertSoftwareVulnerability(ctx, v, source) + if err != nil { + return err + } + + if collectVulns && ok { + inserted = append(inserted, v) + } + } + return nil + }, vulnBatchSize) + if err != nil { + return nil, err + } + + return inserted, nil +} + +// loadDb returns the latest goval_dictionary database for the given platform. +func loadDb(platform oval.Platform, vulnPath string) (*Database, error) { + if !platform.IsGovalDictionarySupported() { + return nil, fmt.Errorf("platform %q not supported", platform) + } + + fileName := platform.ToGovalDictionaryFilename() + latest, err := utils.LatestFile(fileName, vulnPath) + if err != nil { + return nil, err + } + + sqlite, err := sql.Open("sqlite3", latest) + if err != nil { + return nil, err + } + + db := NewDB(sqlite, platform) + return db, nil +} diff --git a/server/vulnerabilities/goval_dictionary/database.go b/server/vulnerabilities/goval_dictionary/database.go new file mode 100644 index 0000000000..1cb8e46ef6 --- /dev/null +++ b/server/vulnerabilities/goval_dictionary/database.go @@ -0,0 +1,87 @@ +package goval_dictionary + +import ( + "database/sql" + "fmt" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/oval" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/utils" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" + "strings" +) + +func NewDB(db *sql.DB, platform oval.Platform) *Database { + return &Database{sqlite: db, platform: platform} +} + +type Database struct { + sqlite *sql.DB + platform oval.Platform +} + +// Eval evaluates the current goval_dictionary database against an OS version and a list of installed software, +// returns all software vulnerabilities found. Logs on any errors so we return as many vulnerabilities as we can. +func (db Database) Eval(software []fleet.Software, logger kitlog.Logger) []fleet.SoftwareVulnerability { + searchStmt := `SELECT packages.version, cves.cve_id + FROM packages join definitions on definitions.id = packages.definition_id + JOIN advisories ON advisories.definition_id = definitions.id JOIN cves ON cves.advisory_id = advisories.id + WHERE packages.name = ? AND packages.arch = ? ORDER BY cve_id, version` + vulnerabilities := make([]fleet.SoftwareVulnerability, 0) + + for _, swItem := range software { + err := func() error { + affectedSoftwareRows, err := db.sqlite.Query(searchStmt, swItem.Name, swItem.Arch) + if err != nil { + return fmt.Errorf("could not query database: %w", err) + } + defer affectedSoftwareRows.Close() + for affectedSoftwareRows.Next() { + var fixedVersionWithEpochPrefix, cve string + if err := affectedSoftwareRows.Scan(&fixedVersionWithEpochPrefix, &cve); err != nil { + level.Error(logger).Log( + "msg", "could not read package vulnerability result", + "package", swItem.Name, + "arch", swItem.Arch, + "platform", db.platform, + "err", err, + ) + continue + } + + var currentVersion string + if swItem.Release != "" { + currentVersion = fmt.Sprintf("%s-%s", swItem.Version, swItem.Release) + } else { + currentVersion = swItem.Version + } + fixedVersion := strings.Split(fixedVersionWithEpochPrefix, ":")[1] + + if utils.Rpmvercmp(currentVersion, fixedVersion) < 0 { + vulnerabilities = append(vulnerabilities, fleet.SoftwareVulnerability{ + SoftwareID: swItem.ID, + CVE: cve, + ResolvedInVersion: &fixedVersion, + }) + } + } + + if affectedSoftwareRows.Err() != nil { + return affectedSoftwareRows.Err() + } + + return nil + }() + if err != nil { + level.Error(logger).Log( + "msg", "could not read package vulnerabilities", + "package", swItem.Name, + "arch", swItem.Arch, + "platform", db.platform, + "err", err, + ) + } + } + + return vulnerabilities +} diff --git a/server/vulnerabilities/goval_dictionary/database_test.go b/server/vulnerabilities/goval_dictionary/database_test.go new file mode 100644 index 0000000000..ed00326cbb --- /dev/null +++ b/server/vulnerabilities/goval_dictionary/database_test.go @@ -0,0 +1,93 @@ +package goval_dictionary + +import ( + "database/sql" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/oval" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/require" + "testing" +) + +func TestDatabase(t *testing.T) { + // build minimal slice of goval-dictionary sqlite schema + sqlite, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + dbSetupQueries := []string{ + // create schema + "CREATE TABLE packages (name TEXT NOT NULL, arch TEXT NOT NULL, version TEXT NOT NULL, definition_id INTEGER NOT NULL)", + "CREATE TABLE definitions (id INTEGER NOT NULL PRIMARY KEY)", + "CREATE TABLE advisories (id INTEGER NOT NULL PRIMARY KEY, definition_id INTEGER NOT NULL)", + "CREATE TABLE cves (cve_id TEXT NOT NULL, advisory_id INTEGER NOT NULL)", + // insert records + `INSERT INTO packages (name, arch, version, definition_id) VALUES + ('expat', 'aarch64', '0:2.1.0-15.amzn2.0.3', 1), ('krb5-server', 'aarch64', '0:1.15.1-55.amzn2.2.8', 2)`, + "INSERT INTO definitions (id) VALUES (1), (2)", + "INSERT INTO advisories (id, definition_id) VALUES (1, 1), (2, 2)", + `INSERT INTO cves (cve_id, advisory_id) VALUES + ('CVE-2022-23990', 1), ('CVE-2022-25313', 1), ('CVE-2024-37370', 2), ('CVE-2024-37371', 2)`, + } + for _, query := range dbSetupQueries { + if _, err := sqlite.Exec(query); err != nil { + t.Fatal(err) + } + } + db := NewDB(sqlite, oval.NewPlatform("amzn", "Amazon Linux 2.0.0")) + logger := kitlog.NewNopLogger() + + t.Run("Non-matching architecture", func(t *testing.T) { + require.Len(t, db.Eval([]fleet.Software{{Name: "expat", Version: "2.1.0", Release: "", Arch: "x86_64"}}, logger), 0) + }) + + t.Run("Non-matching package name", func(t *testing.T) { + require.Len(t, db.Eval([]fleet.Software{{Name: "expath", Version: "2.1.0", Release: "", Arch: "aarch64"}}, logger), 0) + }) + + t.Run("Fixed version", func(t *testing.T) { + require.Len(t, db.Eval([]fleet.Software{{Name: "expath", Version: "2.1.0", Release: "15.amzn2.0.3", Arch: "aarch64"}}, logger), 0) + }) + + t.Run("Newer than fixed version", func(t *testing.T) { + require.Len(t, db.Eval([]fleet.Software{{Name: "expath", Version: "2.1.0", Release: "15.amzn2.0.5", Arch: "aarch64"}}, logger), 0) + }) + + t.Run("Older than fixed version", func(t *testing.T) { + vulns := db.Eval([]fleet.Software{{Name: "expat", Version: "2.1.0", Release: "", Arch: "aarch64", ID: 123}}, logger) + require.Len(t, vulns, 2) + require.Equal(t, "2.1.0-15.amzn2.0.3", *vulns[0].ResolvedInVersion) + require.Equal(t, "2.1.0-15.amzn2.0.3", *vulns[1].ResolvedInVersion) + require.Equal(t, "CVE-2022-23990", vulns[0].CVE) + require.Equal(t, "CVE-2022-25313", vulns[1].CVE) + require.Equal(t, uint(123), vulns[0].SoftwareID) + require.Equal(t, uint(123), vulns[1].SoftwareID) + }) + + t.Run("Multiple packages, fixed version", func(t *testing.T) { + require.Len(t, db.Eval([]fleet.Software{ + {Name: "expat", Version: "2.1.0", Release: "15.amzn2.1.0", Arch: "aarch64"}, + {Name: "krb5-server", Version: "1.15.1", Release: "55.amzn2.2.8", Arch: "aarch64"}, + }, logger), 0) + }) + + t.Run("Multiple packages, multiple vulnerabilities", func(t *testing.T) { + vulns := db.Eval([]fleet.Software{ + {Name: "expat", Version: "2.1.0", Release: "15.amzn2.0.2", Arch: "aarch64", ID: 234}, + {Name: "krb5-server", Version: "1.15.1", Release: "55.amzn2.2.7", Arch: "aarch64", ID: 235}, + }, logger) + require.Len(t, vulns, 4) + + require.Equal(t, "2.1.0-15.amzn2.0.3", *vulns[0].ResolvedInVersion) + require.Equal(t, "2.1.0-15.amzn2.0.3", *vulns[1].ResolvedInVersion) + require.Equal(t, "1.15.1-55.amzn2.2.8", *vulns[2].ResolvedInVersion) + require.Equal(t, "CVE-2022-23990", vulns[0].CVE) + require.Equal(t, "CVE-2022-25313", vulns[1].CVE) + require.Equal(t, "CVE-2024-37370", vulns[2].CVE) + require.Equal(t, "CVE-2024-37371", vulns[3].CVE) + require.Equal(t, uint(234), vulns[0].SoftwareID) + require.Equal(t, uint(234), vulns[1].SoftwareID) + require.Equal(t, uint(235), vulns[2].SoftwareID) + require.Equal(t, uint(235), vulns[3].SoftwareID) + }) +} diff --git a/server/vulnerabilities/goval_dictionary/sync.go b/server/vulnerabilities/goval_dictionary/sync.go new file mode 100644 index 0000000000..e89fab5120 --- /dev/null +++ b/server/vulnerabilities/goval_dictionary/sync.go @@ -0,0 +1,89 @@ +package goval_dictionary + +import ( + "fmt" + "github.com/fleetdm/fleet/v4/pkg/download" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/oval" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" + "net/http" + "net/url" + "path/filepath" +) + +func Refresh( + versions *fleet.OSVersions, + vulnPath string, + logger kitlog.Logger, +) ([]oval.Platform, error) { + toDownload := whatToDownload(versions) + if len(toDownload) > 0 { + level.Debug(logger).Log("msg", "goval_dictionary-sync-downloading") + err := Sync(vulnPath, toDownload) + if err != nil { + return nil, err + } + } + + return toDownload, nil +} + +func Sync(dstDir string, platforms []oval.Platform) error { + client := fleethttp.NewClient() + dwn := downloadDecompressed(client) + basePath, err := nvd.GetGitHubCVEAssetPath() + if err != nil { + return err + } + + for _, platform := range platforms { + if err := downloadDatabase(platform, dwn, basePath, dstDir); err != nil { + return err + } + } + return nil +} + +func downloadDatabase( + platform oval.Platform, + downloader func(string, string) error, + basePath string, + vulnDir string, +) error { + dstPath := filepath.Join(vulnDir, platform.ToGovalDictionaryFilename()) + if err := downloader(basePath+string(platform)+".sqlite3.xz", dstPath); err != nil { + return err + } + + return nil +} + +func downloadDecompressed(client *http.Client) func(string, string) error { + return func(u, dstPath string) error { + parsedUrl, err := url.Parse(u) + if err != nil { + return fmt.Errorf("url parse: %w", err) + } + + if err = download.DownloadAndExtract(client, parsedUrl, dstPath); err != nil { + return fmt.Errorf("download and extract url %s: %w", parsedUrl, err) + } + + return nil + } +} + +func whatToDownload(osVers *fleet.OSVersions) []oval.Platform { + var r []oval.Platform + for _, os := range osVers.OSVersions { + platform := oval.NewPlatform(os.Platform, os.Name) + if platform.IsGovalDictionarySupported() { + r = append(r, platform) + } + } + + return r +} diff --git a/server/vulnerabilities/goval_dictionary/sync_test.go b/server/vulnerabilities/goval_dictionary/sync_test.go new file mode 100644 index 0000000000..cec05521c3 --- /dev/null +++ b/server/vulnerabilities/goval_dictionary/sync_test.go @@ -0,0 +1,34 @@ +package goval_dictionary + +import ( + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/oval" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestSync(t *testing.T) { + t.Run("#whatToDownload", func(t *testing.T) { + osVersions := fleet.OSVersions{ + CountsUpdatedAt: time.Now(), + OSVersions: []fleet.OSVersion{ + { + HostsCount: 1, + Platform: "ubuntu", + Name: "Ubuntu 20.4.0", + }, + { + HostsCount: 1, + Platform: "amzn", + Name: "Amazon Linux 2.0.0", + }, + }, + } + + result := whatToDownload(&osVersions) + require.Len(t, result, 1) + require.Contains(t, result, oval.NewPlatform("amzn", "Amazon Linux 2.0.0")) + require.NotContains(t, result, oval.NewPlatform("ubuntu", "Ubuntu 20.4.0")) + }) +} diff --git a/server/vulnerabilities/io/github_test.go b/server/vulnerabilities/io/github_test.go index 9b627c4ebb..5735daa9dc 100644 --- a/server/vulnerabilities/io/github_test.go +++ b/server/vulnerabilities/io/github_test.go @@ -168,7 +168,8 @@ func (m mockGHReleaseLister) ListReleases( return releases, res, nil } -func TestGithubClient(t *testing.T) { +func TestIntegrationsGithubClient(t *testing.T) { + t.Parallel() ctx := context.Background() t.Run("MacOfficeReleaseNotes", func(t *testing.T) { diff --git a/server/vulnerabilities/macoffice/integration_analyzer_test.go b/server/vulnerabilities/macoffice/integration_analyzer_test.go index b36592f656..3808605dd9 100644 --- a/server/vulnerabilities/macoffice/integration_analyzer_test.go +++ b/server/vulnerabilities/macoffice/integration_analyzer_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestIntegrationAnalyzer(t *testing.T) { +func TestIntegrationsAnalyzer(t *testing.T) { ds := mysql.CreateMySQLDS(t) vulnPath := t.TempDir() releaseNotes := macoffice.ReleaseNotes{ diff --git a/server/vulnerabilities/macoffice/integration_parser_test.go b/server/vulnerabilities/macoffice/integration_parser_test.go index 9aa92c8ee4..d8f218d326 100644 --- a/server/vulnerabilities/macoffice/integration_parser_test.go +++ b/server/vulnerabilities/macoffice/integration_parser_test.go @@ -653,7 +653,7 @@ var expected = []macoffice.ReleaseNote{ }, } -func TestIntegrationParseReleaseHTML(t *testing.T) { +func TestIntegrationsParseReleaseHTML(t *testing.T) { nettest.Run(t) res, err := http.Get(macoffice.RelNotesURL) diff --git a/server/vulnerabilities/macoffice/integration_sync_test.go b/server/vulnerabilities/macoffice/integration_sync_test.go index 3fd55c80e5..1dc6dbfec0 100644 --- a/server/vulnerabilities/macoffice/integration_sync_test.go +++ b/server/vulnerabilities/macoffice/integration_sync_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestIntegrationSync(t *testing.T) { +func TestIntegrationsSync(t *testing.T) { nettest.Run(t) vulnPath := t.TempDir() diff --git a/server/vulnerabilities/nvd/cpe.go b/server/vulnerabilities/nvd/cpe.go index 9d20820d08..d1d9f1944a 100644 --- a/server/vulnerabilities/nvd/cpe.go +++ b/server/vulnerabilities/nvd/cpe.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "time" "unicode" @@ -439,11 +440,12 @@ func TranslateSoftwareToCPE( vulnPath string, logger kitlog.Logger, ) error { - // Skip software from sources for which we will be using OVAL for vulnerability detection. + // Skip software from sources for which we will be using OVAL or goval-dictionary for vulnerability detection. nonOvalIterator, err := ds.AllSoftwareIterator( ctx, fleet.SoftwareIterQueryOptions{ - ExcludedSources: oval.SupportedSoftwareSources, + // Also exclude iOS and iPadOS apps until we enable vulnerabilities support for them. + ExcludedSources: append(oval.SupportedSoftwareSources, "ios_apps", "ipados_apps"), }, ) if err != nil { @@ -566,9 +568,14 @@ func translateSoftwareToCPEWithIterator( return nil } +var allowedNonASCII = []int32{ + '–', // en dash + '—', // em dash +} + func containsNonASCII(s string) bool { for _, char := range s { - if char > unicode.MaxASCII { + if char > unicode.MaxASCII && !slices.Contains(allowedNonASCII, char) { return true } } diff --git a/server/vulnerabilities/nvd/cpe_matching_rules.go b/server/vulnerabilities/nvd/cpe_matching_rules.go index d7f0e60deb..48821344d0 100644 --- a/server/vulnerabilities/nvd/cpe_matching_rules.go +++ b/server/vulnerabilities/nvd/cpe_matching_rules.go @@ -240,6 +240,15 @@ func GetKnownNVDBugRules() (CPEMatchingRules, error) { }, IgnoreAll: true, }, + // CVE-2024-4030 only targets windows operating systems + CPEMatchingRule{ + CVEs: map[string]struct{}{ + "CVE-2024-4030": {}, + }, + IgnoreIf: func(cpeMeta *wfn.Attributes) bool { + return cpeMeta.TargetSW != "windows" + }, + }, } for i, rule := range rules { diff --git a/server/vulnerabilities/nvd/cpe_test.go b/server/vulnerabilities/nvd/cpe_test.go index dc2f598fa8..386234d8ac 100644 --- a/server/vulnerabilities/nvd/cpe_test.go +++ b/server/vulnerabilities/nvd/cpe_test.go @@ -676,7 +676,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: "", @@ -762,7 +762,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { Version: "18.9.0", Vendor: "", BundleIdentifier: "", - }, cpe: "cpe:2.3:a:nodejs:node.js:18.9.0:*:*:*:*:*:*:*", + }, cpe: "cpe:2.3:a:nodejs:node.js:18.9.0:*:*:*:*:macos:*:*", }, { software: fleet.Software{ @@ -1342,7 +1342,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { Vendor: "", BundleIdentifier: "", }, - cpe: "cpe:2.3:a:jetbrains:intellij_idea:2023.3.2.233.13135.103:*:*:*:*:*:*:*", + cpe: "cpe:2.3:a:jetbrains:intellij_idea:2023.3.2.233.13135.103:*:*:*:*:macos:*:*", }, { software: fleet.Software{ @@ -1643,7 +1643,7 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { Version: "3.9.18_2", Vendor: "", }, - cpe: `cpe:2.3:a:python:python:3.9.18_2:*:*:*:*:*:*:*`, + cpe: `cpe:2.3:a:python:python:3.9.18_2:*:*:*:*:macos:*:*`, }, { software: fleet.Software{ @@ -1654,6 +1654,15 @@ func TestCPEFromSoftwareIntegration(t *testing.T) { }, cpe: "cpe:2.3:o:linux:linux_kernel:5.4.0-105.118:*:*:*:*:*:*:*", }, + { + software: fleet.Software{ + Name: "VirtualBox.app", + Source: "apps", + Version: "7.0.12", + BundleIdentifier: "org.virtualbox.app.VirtualBox", + }, + cpe: "cpe:2.3:a:oracle:virtualbox:7.0.12:*:*:*:*:macos:*:*", + }, } // NVD_TEST_CPEDB_PATH can be used to speed up development (sync cpe.sqlite only once). @@ -1693,7 +1702,7 @@ func TestContainsNonASCII(t *testing.T) { }{ {"hello", false}, {"hello world", false}, - {"hello world!", false}, + {"hello – world!", false}, {"😊👍", true}, {"hello world! 😊👍", true}, {"Девушка Фонарём", true}, diff --git a/server/vulnerabilities/nvd/cpe_translations.json b/server/vulnerabilities/nvd/cpe_translations.json index 73d64cd787..ec03b2e26c 100644 --- a/server/vulnerabilities/nvd/cpe_translations.json +++ b/server/vulnerabilities/nvd/cpe_translations.json @@ -407,5 +407,24 @@ "vendor": ["linux"], "part": "o" } + }, + { + "software": { + "name": ["git"], + "source": ["homebrew_packages"] + }, + "filter": { + "product": ["git"], + "vendor": ["git"] + } + }, + { + "software": { + "bundle_identifier": ["org.virtualbox.app.VirtualBox"] + }, + "filter": { + "product": ["virtualbox"], + "vendor": ["oracle"] + } } -] +] \ No newline at end of file diff --git a/server/vulnerabilities/nvd/cve.go b/server/vulnerabilities/nvd/cve.go index f7bc5fa49c..7b321ef947 100644 --- a/server/vulnerabilities/nvd/cve.go +++ b/server/vulnerabilities/nvd/cve.go @@ -588,6 +588,14 @@ func expandCPEAliases(cpeItem *wfn.Attributes) []*wfn.Attributes { } } + for _, cpeItem := range cpeItems { + if cpeItem.Vendor == "oracle" && cpeItem.Product == "virtualbox" { + cpeItem2 := *cpeItem + cpeItem2.Product = "vm_virtualbox" + cpeItems = append(cpeItems, &cpeItem2) + } + } + return cpeItems } diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index c69897179d..691f3e321a 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -320,6 +320,7 @@ func TestTranslateCPEToCVE(t *testing.T) { }, excludedCVEs: []string{ "CVE-2023-28205", // This vulnerability is for Safari 16.4.0 + "CVE-2024-23252", // Rejected CVE }, continuesToUpdate: true, }, @@ -336,6 +337,24 @@ func TestTranslateCPEToCVE(t *testing.T) { excludedCVEs: []string{"CVE-2011-5049"}, // OS vulnerability continuesToUpdate: true, }, + "cpe:2.3:a:python:python:3.9.6:*:*:*:*:macos:*:*": { + excludedCVEs: []string{"CVE-2024-4030"}, + continuesToUpdate: true, + }, + "cpe:2.3:a:python:python:3.9.6:*:*:*:*:windows:*:*": { + includedCVEs: []cve{ + {ID: "CVE-2024-4030", resolvedInVersion: "3.12.4"}, + }, + continuesToUpdate: true, + }, + // Tests the expandCPEAliases rule for virtualbox on macOS + "cpe:2.3:a:oracle:virtualbox:7.0.6:*:*:*:*:macos:*:*": { + includedCVEs: []cve{ + {ID: "CVE-2023-21989", resolvedInVersion: "7.0.8"}, + {ID: "CVE-2024-21141", resolvedInVersion: "7.0.20"}, + }, + continuesToUpdate: true, + }, } cveOSTests := []struct { diff --git a/server/vulnerabilities/nvd/sanitize.go b/server/vulnerabilities/nvd/sanitize.go index 40e0be0ddc..dce39c36a3 100644 --- a/server/vulnerabilities/nvd/sanitize.go +++ b/server/vulnerabilities/nvd/sanitize.go @@ -222,6 +222,8 @@ func targetSW(s *fleet.Software) string { switch s.Source { case "apps": return "macos" + case "homebrew_packages": + return "macos" // osquery homebrew_packages table is currently only for macOS (2024/08/12) case "python_packages": return "python" case "chrome_extensions": diff --git a/server/vulnerabilities/nvd/sync.go b/server/vulnerabilities/nvd/sync.go index c2e52cc40a..65174e721b 100644 --- a/server/vulnerabilities/nvd/sync.go +++ b/server/vulnerabilities/nvd/sync.go @@ -216,9 +216,10 @@ func LoadCVEMeta(ctx context.Context, logger log.Logger, vulnPath string, ds fle } schema := vuln.Schema() - meta := fleet.CVEMeta{ - CVE: cve, - Description: schema.CVE.Description.DescriptionData[0].Value, + meta := fleet.CVEMeta{CVE: cve} + + if len(schema.CVE.Description.DescriptionData) > 0 { + meta.Description = schema.CVE.Description.DescriptionData[0].Value } if schema.Impact.BaseMetricV3 != nil { diff --git a/server/vulnerabilities/nvd/sync/cve_syncer.go b/server/vulnerabilities/nvd/sync/cve_syncer.go index bad1289a36..8ecdb8e8da 100644 --- a/server/vulnerabilities/nvd/sync/cve_syncer.go +++ b/server/vulnerabilities/nvd/sync/cve_syncer.go @@ -780,16 +780,35 @@ func convertAPI20CVEToLegacy(cve nvdapi.CVE, logger log.Logger) *schema.NVDCVEFe descriptions := make([]*schema.CVEJSON40LangString, 0, len(cve.Descriptions)) for _, description := range cve.Descriptions { - // Keep only english descriptions to match the legacy. - if description.Lang != "en" { + // Keep only English descriptions to match the legacy format. + var lang string + switch description.Lang { + case "en": + lang = description.Lang + case "en-US": // This occurred starting with Microsoft CVE-2024-38200. + lang = "en" + // non-English descriptions with known language tags are ignored. + case "es": // This occurred in a number of 2004 CVEs + continue + // non-English descriptions with unknown language tags are ignored and warned. + default: + level.Warn(logger).Log("msg", "Unknown CVE description language tag", "lang", description.Lang) continue } descriptions = append(descriptions, &schema.CVEJSON40LangString{ - Lang: description.Lang, + Lang: lang, Value: description.Value, }) } + if len(descriptions) == 0 { + // Populate a blank description to prevent Fleet cron job from crashing: https://github.com/fleetdm/fleet/issues/21239 + descriptions = append(descriptions, &schema.CVEJSON40LangString{ + Lang: "en", + Value: "", + }) + } + problemtypeData := make([]*schema.CVEJSON40ProblemtypeProblemtypeData, 0, len(cve.Weaknesses)) if len(cve.Weaknesses) == 0 { problemtypeData = append(problemtypeData, &schema.CVEJSON40ProblemtypeProblemtypeData{ diff --git a/server/vulnerabilities/nvd/sync/cve_syncer_test.go b/server/vulnerabilities/nvd/sync/cve_syncer_test.go index b25e0fbefc..acbc0656f0 100644 --- a/server/vulnerabilities/nvd/sync/cve_syncer_test.go +++ b/server/vulnerabilities/nvd/sync/cve_syncer_test.go @@ -31,6 +31,7 @@ var ( ) func TestStoreCVEsLegacyFormat(t *testing.T) { + t.Parallel() year := 2023 t.Run(fmt.Sprintf("%d", year), func(t *testing.T) { // Load CVEs from legacy feed. diff --git a/server/vulnerabilities/oval/analyzer.go b/server/vulnerabilities/oval/analyzer.go index f64d97b141..427761c16a 100644 --- a/server/vulnerabilities/oval/analyzer.go +++ b/server/vulnerabilities/oval/analyzer.go @@ -3,6 +3,7 @@ package oval import ( "context" "encoding/json" + "errors" "fmt" "os" "time" @@ -17,8 +18,11 @@ const ( vulnBatchSize = 500 ) +var ErrUnsupportedPlatform = errors.New("unsupported platform") + // Analyze scans all hosts for vulnerabilities based on the OVAL definitions for their platform, -// inserting any new vulnerabilities and deleting anything patched. +// inserting any new vulnerabilities and deleting anything patched. Returns nil, nil when +// the platform isn't supported. func Analyze( ctx context.Context, ds fleet.Datastore, @@ -34,7 +38,7 @@ func Analyze( } if !platform.IsSupported() { - return nil, nil + return nil, ErrUnsupportedPlatform } defs, err := loadDef(platform, vulnPath) diff --git a/server/vulnerabilities/oval/analyzer_test.go b/server/vulnerabilities/oval/analyzer_test.go index c978a85a47..415c1ca4e6 100644 --- a/server/vulnerabilities/oval/analyzer_test.go +++ b/server/vulnerabilities/oval/analyzer_test.go @@ -121,7 +121,7 @@ func extractFixtures( extract(srcCvesPath, dstCvesPath, t) } -func withTestFixutre( +func withTestFixture( version fleet.OSVersion, ovalFixtureDir string, softwareFixtureDir string, @@ -218,7 +218,7 @@ func BenchmarkTestOvalAnalyzer(b *testing.B) { for _, v := range systems { b.Run(fmt.Sprintf("for %s %s", v.Platform, v.Name), func(b *testing.B) { - withTestFixutre(v, ovalFixtureDir, softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { + withTestFixture(v, ovalFixtureDir, softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { b.ResetTimer() for i := 0; i < b.N; i++ { _, err := Analyze(context.Background(), ds, v, vulnPath, true) @@ -269,7 +269,7 @@ func BenchmarkTestOvalAnalyzer(b *testing.B) { for _, v := range systems { b.Run(fmt.Sprintf("for %s %s", v.version.Platform, v.version.Name), func(b *testing.B) { - withTestFixutre(v.version, v.ovalFixtureDir, v.softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { + withTestFixture(v.version, v.ovalFixtureDir, v.softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { b.ResetTimer() for i := 0; i < b.N; i++ { _, err := Analyze(context.Background(), ds, v.version, vulnPath, true) @@ -323,7 +323,7 @@ func TestOvalAnalyzer(t *testing.T) { } for _, s := range systems { - withTestFixutre(s.version, s.ovalFixtureDir, s.softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { + withTestFixture(s.version, s.ovalFixtureDir, s.softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { _, err := Analyze(ctx, ds, s.version, vulnPath, true) require.NoError(t, err) p := NewPlatform(s.version.Platform, s.version.Name) @@ -355,7 +355,7 @@ func TestOvalAnalyzer(t *testing.T) { ovalFixtureDir := "ubuntu" softwareFixtureDir := filepath.Join("ubuntu", "software") for _, v := range systems { - withTestFixutre(v, ovalFixtureDir, softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { + withTestFixture(v, ovalFixtureDir, softwareFixtureDir, vulnPath, ds, func(h *fleet.Host) { _, err := Analyze(ctx, ds, v, vulnPath, true) require.NoError(t, err) diff --git a/server/vulnerabilities/oval/oval_platform.go b/server/vulnerabilities/oval/oval_platform.go index 4b7c4a4923..c9fcc7ba1b 100644 --- a/server/vulnerabilities/oval/oval_platform.go +++ b/server/vulnerabilities/oval/oval_platform.go @@ -13,8 +13,9 @@ type Platform string // OvalFilePrefix is the file prefix used when saving an OVAL artifact. const OvalFilePrefix = "fleet_oval" +const GovalDictionaryFilePrefix = "fleet_goval_dictionary" -// SupportedSoftwareSources are the software sources for which we are using OVAL for vulnerability detection. +// SupportedSoftwareSources are the software sources for which we are using OVAL or goval-dictionary for vulnerability detection. var SupportedSoftwareSources = []string{"deb_packages", "rpm_packages"} // getMajorMinorVer returns the major and minor version of an 'os_version'. @@ -69,6 +70,10 @@ func (op Platform) ToFilename(date time.Time, extension string) string { return fmt.Sprintf("%s_%s-%d_%02d_%02d.%s", OvalFilePrefix, op, date.Year(), date.Month(), date.Day(), extension) } +func (op Platform) ToGovalDictionaryFilename() string { + return fmt.Sprintf("%s_%s.sqlite3", GovalDictionaryFilePrefix, op) +} + // IsSupported returns whether the given platform is currently supported. func (op Platform) IsSupported() bool { supported := []string{ @@ -89,7 +94,6 @@ func (op Platform) IsSupported() bool { "rhel_07", "rhel_08", "rhel_09", - "amzn_02", } for _, p := range supported { if strings.HasPrefix(string(op), p) { @@ -99,6 +103,22 @@ func (op Platform) IsSupported() bool { return false } +func (op Platform) IsGovalDictionarySupported() bool { + supported := []string{ + "amzn_01", + "amzn_02", + "amzn_2022", + "amzn_2023", + } + + for _, p := range supported { + if strings.HasPrefix(string(op), p) { + return true + } + } + return false +} + // IsUbuntu checks whether the current Platform targets Ubuntu. func (op Platform) IsUbuntu() bool { return strings.HasPrefix(string(op), "ubuntu") diff --git a/server/vulnerabilities/oval/oval_platform_test.go b/server/vulnerabilities/oval/oval_platform_test.go index bf9a5e4a41..86f402f885 100644 --- a/server/vulnerabilities/oval/oval_platform_test.go +++ b/server/vulnerabilities/oval/oval_platform_test.go @@ -27,6 +27,7 @@ func TestOvalPlatform(t *testing.T) { {"ubuntu", "Ubuntu 18.4.0 LTS asdfasd", "ubuntu_1804"}, {"rhel", "CentOS Linux 7.9.2009", "rhel_07"}, {"amzn", "Amazon Linux 2.0.0", "amzn_02"}, + {"amzn", "Amazon Linux 2023.0.0", "amzn_2023"}, {"rhel", "Fedora Linux 12.0.0", "rhel_06"}, {"rhel", "Fedora Linux 13.0.0", "rhel_06"}, {"rhel", "Fedora Linux 14.0.0", "rhel_06"}, @@ -73,4 +74,18 @@ func TestOvalPlatform(t *testing.T) { require.Equal(t, c.expected, plat.ToFilename(c.date, "json")) } }) + + t.Run("ToGovalDictionaryFilename", func(t *testing.T) { + cases := []struct { + version string + expected string + }{ + {"Amazon Linux 2.0.0", "fleet_goval_dictionary_amzn_02.sqlite3"}, + {"Amazon Linux 2023.0.0", "fleet_goval_dictionary_amzn_2023.sqlite3"}, + } + for _, c := range cases { + plat := NewPlatform("amzn", c.version) + require.Equal(t, c.expected, plat.ToGovalDictionaryFilename()) + } + }) } diff --git a/server/webhooks/failing_policies.go b/server/webhooks/failing_policies.go index 53c1b112a8..7af7610d04 100644 --- a/server/webhooks/failing_policies.go +++ b/server/webhooks/failing_policies.go @@ -36,6 +36,12 @@ func SendFailingPoliciesBatchedPOSTs( level.Debug(logger).Log("msg", "no hosts", "policyID", policy.ID) return nil } + // The count may be out of date since it is only updated during the hourly cleanups_then_aggregation cron. + // Take care of the case where the count is less than the actual number of hosts we are returning. + hostsCount := uint(len(hosts)) + if hostsCount > policy.FailingHostCount { + policy.FailingHostCount = hostsCount + } sort.Slice(hosts, func(i, j int) bool { return hosts[i].ID < hosts[j].ID }) diff --git a/server/webhooks/failing_policies_test.go b/server/webhooks/failing_policies_test.go index 28b9048ea4..4a75d4758e 100644 --- a/server/webhooks/failing_policies_test.go +++ b/server/webhooks/failing_policies_test.go @@ -122,7 +122,7 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) { "created_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z", "passing_host_count": 0, - "failing_host_count": 0, + "failing_host_count": 2, "host_count_updated_at": null, "critical": true, "calendar_events_enabled": false @@ -309,7 +309,7 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) { "created_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z", "passing_host_count": 0, - "failing_host_count": 0, + "failing_host_count": 1, "host_count_updated_at": null, "critical": false, "calendar_events_enabled": true diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index 66d47070ba..fadd38dd24 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -51,6 +51,8 @@ func TestAppleMDM(t *testing.T) { // use this to debug/verify details of calls nopLog := kitlog.NewJSONLogger(os.Stdout) + testOrgName := "fleet-test" + createEnrolledHost := func(t *testing.T, i int, teamID *uint, depAssignedToFleet bool) *fleet.Host { // create the host h, err := ds.NewHost(ctx, &fleet.Host{ @@ -65,6 +67,7 @@ func TestAppleMDM(t *testing.T) { require.NoError(t, err) // create the nano_device and enrollment + var abmTokenID uint mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `INSERT INTO nano_devices (id, serial_number, authenticate) VALUES (?, ?, ?)`, h.UUID, h.HardwareSerial, "test") if err != nil { @@ -72,10 +75,18 @@ func TestAppleMDM(t *testing.T) { } _, err = q.ExecContext(ctx, `INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex) VALUES (?, ?, ?, ?, ?, ?)`, h.UUID, h.UUID, "device", "topic", "push_magic", "token_hex") + if err != nil { + return err + } + + encTok := uuid.NewString() + abmToken, err := ds.InsertABMToken(ctx, &fleet.ABMToken{OrganizationName: "unused", EncryptedToken: []byte(encTok)}) + abmTokenID = abmToken.ID + return err }) if depAssignedToFleet { - err := ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + err := ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, abmTokenID) require.NoError(t, err) } err = ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "http://example.com", depAssignedToFleet, fleet.WellKnownMDMFleet, "") @@ -118,7 +129,7 @@ func TestAppleMDM(t *testing.T) { } t.Run("no-op with nil commander", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) mdmWorker := &AppleMDM{ @@ -147,7 +158,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("fails with unknown task", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) mdmWorker := &AppleMDM{ @@ -179,7 +190,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs default manifest", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) h := createEnrolledHost(t, 1, nil, true) @@ -217,7 +228,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs default manifest, manual release", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) t.Cleanup(func() { mysql.TruncateTables(t, ds) }) h := createEnrolledHost(t, 1, nil, true) @@ -252,7 +263,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs custom bootstrap manifest", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) h := createEnrolledHost(t, 1, nil, true) @@ -262,7 +273,7 @@ func TestAppleMDM(t *testing.T) { Bytes: []byte("test"), Sha256: []byte("test"), Token: "token", - }) + }, nil) require.NoError(t, err) mdmWorker := &AppleMDM{ @@ -301,7 +312,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs custom bootstrap manifest of a team", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) @@ -314,7 +325,7 @@ func TestAppleMDM(t *testing.T) { Bytes: []byte("test"), Sha256: []byte("test"), Token: "token", - }) + }, nil) require.NoError(t, err) mdmWorker := &AppleMDM{ @@ -353,7 +364,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs custom bootstrap manifest of a team, manual release", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) t.Cleanup(func() { mysql.TruncateTables(t, ds) }) tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) @@ -367,7 +378,7 @@ func TestAppleMDM(t *testing.T) { Bytes: []byte("test"), Sha256: []byte("test"), Token: "token", - }) + }, nil) require.NoError(t, err) mdmWorker := &AppleMDM{ @@ -403,7 +414,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("unknown enroll reference", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) h := createEnrolledHost(t, 1, nil, true) @@ -436,7 +447,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("enroll reference but SSO disabled", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) err := ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ @@ -484,7 +495,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("enroll reference with SSO enabled", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) err := ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ @@ -539,7 +550,7 @@ func TestAppleMDM(t *testing.T) { }) t.Run("installs fleetd for manual enrollments", func(t *testing.T) { - mysql.SetTestABMAssets(t, ds) + mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds) h := createEnrolledHost(t, 1, nil, true) diff --git a/server/worker/db_migrations.go b/server/worker/db_migrations.go new file mode 100644 index 0000000000..88d0839d19 --- /dev/null +++ b/server/worker/db_migrations.go @@ -0,0 +1,130 @@ +package worker + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" +) + +// Name of the DB migration job as registered in the worker. Note that although +// it is a single job, it can process a number of different-but-related tasks, +// identified by the Task field in the job's payload. This is deliberately +// general so other one-off migration tasks that can't be done during the +// "fleet prepare db" command can reuse this job. +const dbMigrationJobName = "db_migration" + +type DBMigrationTask string + +// List of supported tasks. +const ( + DBMigrateVPPTokenTask DBMigrationTask = "migrate_vpp_token" //nolint: gosec +) + +// DBMigration is the job processor for the db_migration job. +type DBMigration struct { + Datastore fleet.Datastore + Log kitlog.Logger +} + +// Name returns the name of the job. +func (m *DBMigration) Name() string { + return dbMigrationJobName +} + +// dbMigrationArgs is the payload for the DB migration job. +type dbMigrationArgs struct { + Task DBMigrationTask `json:"task"` +} + +// Run executes the db_migration job. Note that unlike for other worker jobs, +// there is no QueueDBMigrationJob function - it is expected that this job will +// always be enqueued by a database migration, which use a direct INSERT into +// the jobs table to avoid depending on code that may change over time. +func (m *DBMigration) Run(ctx context.Context, argsJSON json.RawMessage) error { + var args dbMigrationArgs + if err := json.Unmarshal(argsJSON, &args); err != nil { + return ctxerr.Wrap(ctx, err, "unmarshal args") + } + + switch args.Task { + case DBMigrateVPPTokenTask: + err := m.migrateVPPToken(ctx) + return ctxerr.Wrap(ctx, err, "running migrate VPP token task") + + default: + return ctxerr.Errorf(ctx, "unknown task: %v", args.Task) + } +} + +func (m *DBMigration) migrateVPPToken(ctx context.Context) error { + // get the VPP token with an empty location, this is the one to migrate + tok, err := m.Datastore.GetVPPTokenByLocation(ctx, "") + if err != nil { + if fleet.IsNotFound(err) { + // nothing to migrate, exit successfully + return nil + } + return ctxerr.Wrap(ctx, err, "get VPP token to migrate") + } + + tokenData, didUpdate, err := extractVPPTokenFromMigration(tok) + if err != nil { + return ctxerr.Wrap(ctx, err, "extract VPP token metadata") + } + if !didUpdate { + // it should've updated, as the location, org name and renew date were all + // dummy values after the DB migration. Log something, but otherwise + // continue as retrying won't change the result. + m.Log.Log("info", "VPP token metadata was not updated") + } + + if _, err := m.Datastore.UpdateVPPToken(ctx, tok.ID, tokenData); err != nil { + return ctxerr.Wrap(ctx, err, "update VPP token") + } + // the migated token should target "All teams" + _, err = m.Datastore.UpdateVPPTokenTeams(ctx, tok.ID, []uint{}) + return ctxerr.Wrap(ctx, err, "update VPP token teams") +} + +func extractVPPTokenFromMigration(migratedToken *fleet.VPPTokenDB) (tokData *fleet.VPPTokenData, didUpdateMetadata bool, err error) { + var vppTokenData fleet.VPPTokenData + if err := json.Unmarshal([]byte(migratedToken.Token), &vppTokenData); err != nil { + return nil, false, fmt.Errorf("unmarshaling VPP token data: %w", err) + } + + vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token) + if err != nil { + return nil, false, fmt.Errorf("decoding raw vpp token data: %w", err) + } + + var vppTokenRaw fleet.VPPTokenRaw + if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil { + return nil, false, fmt.Errorf("unmarshaling raw vpp token data: %w", err) + } + + exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate) + if err != nil { + return nil, false, fmt.Errorf("parsing vpp token expiration date: %w", err) + } + + if vppTokenData.Location != migratedToken.Location { + migratedToken.Location = vppTokenData.Location + didUpdateMetadata = true + } + if vppTokenRaw.OrgName != migratedToken.OrgName { + migratedToken.OrgName = vppTokenRaw.OrgName + didUpdateMetadata = true + } + if !exp.Equal(migratedToken.RenewDate) { + migratedToken.RenewDate = exp.UTC() + didUpdateMetadata = true + } + + return &vppTokenData, didUpdateMetadata, nil +} diff --git a/server/worker/db_migrations_test.go b/server/worker/db_migrations_test.go new file mode 100644 index 0000000000..1aeeb697e9 --- /dev/null +++ b/server/worker/db_migrations_test.go @@ -0,0 +1,136 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + kitlog "github.com/go-kit/log" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDBMigrationsVPPToken(t *testing.T) { + ctx := context.Background() + + ds := mysql.CreateMySQLDS(t) + // call TruncateTables immediately as a DB migration may have created jobs + mysql.TruncateTables(t, ds) + + nopLog := kitlog.NewNopLogger() + // use this to debug/verify details of calls + // nopLog := kitlog.NewJSONLogger(os.Stdout) + + // create and register the worker + processor := &DBMigration{ + Datastore: ds, + Log: nopLog, + } + w := NewWorker(ds, nopLog) + w.Register(processor) + + // create the migrated token and enqueue the job + expDate := time.Date(2024, 8, 27, 0, 0, 0, 0, time.UTC) + tok, err := test.CreateVPPTokenEncodedAfterMigration(expDate, "test-org", "test-loc") + require.NoError(t, err) + encTok, err := mysql.EncryptWithPrivateKey(t, ds, tok) + require.NoError(t, err) + + const insVPP = ` +INSERT INTO vpp_tokens + ( + organization_name, + location, + renew_at, + token + ) +VALUES + ('', '', DATE('2000-01-01'), ?) +` + + const insJob = ` +INSERT INTO jobs ( + name, + args, + state, + error, + not_before, + created_at, + updated_at +) +VALUES (?, ?, ?, '', ?, ?, ?) +` + mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, insVPP, encTok) + if err != nil { + return err + } + + argsJSON, err := json.Marshal(dbMigrationArgs{Task: DBMigrateVPPTokenTask}) + if err != nil { + return fmt.Errorf("failed to JSON marshal the job arguments: %w", err) + } + ts := time.Date(2024, 8, 26, 0, 0, 0, 0, time.UTC) + if _, err := q.ExecContext(ctx, insJob, dbMigrationJobName, argsJSON, fleet.JobStateQueued, ts, ts, ts); err != nil { + return err + } + return nil + }) + + // run the worker, should mark the job as done + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + // nothing more to run + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job + require.NoError(t, err) + if !assert.Empty(t, jobs) { + t.Logf(">>> %#+v", jobs[0]) + } + + // token should've been updated + vppTok, err := ds.GetVPPTokenByLocation(ctx, "test-loc") + require.NoError(t, err) + require.Equal(t, "test-org", vppTok.OrgName) + require.Equal(t, "test-loc", vppTok.Location) + require.Equal(t, expDate, vppTok.RenewDate) + require.Contains(t, string(tok), `"token":"`+vppTok.Token+`"`) // the DB-stored token is the "token" JSON field in the raw tok + require.NotNil(t, vppTok.Teams) + require.Len(t, vppTok.Teams, 0) + + // empty-location token should not exist anymore + _, err = ds.GetVPPTokenByLocation(ctx, "") + require.Error(t, err) + var nfe fleet.NotFoundError + require.ErrorAs(t, err, &nfe) + + // enqueue a DB migration job with an unknown task + mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + argsJSON, err := json.Marshal(dbMigrationArgs{Task: DBMigrationTask("no-such-task")}) + if err != nil { + return fmt.Errorf("failed to JSON marshal the job arguments: %w", err) + } + ts := time.Date(2024, 8, 26, 0, 0, 0, 0, time.UTC) + if _, err := q.ExecContext(ctx, insJob, dbMigrationJobName, argsJSON, fleet.JobStateQueued, ts, ts, ts); err != nil { + return err + } + return nil + }) + + // run the worker, will fail but still queued for a retry + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + jobs, err = ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, fleet.JobStateQueued, jobs[0].State) + require.Equal(t, 1, jobs[0].Retries) + require.Contains(t, jobs[0].Error, "unknown task: no-such-task") +} diff --git a/server/worker/macos_setup_assistant.go b/server/worker/macos_setup_assistant.go index 55f808c4ea..d473917138 100644 --- a/server/worker/macos_setup_assistant.go +++ b/server/worker/macos_setup_assistant.go @@ -96,20 +96,6 @@ func (m *MacosSetupAssistant) runProfileChanged(ctx context.Context, args macosS return ctxerr.Wrap(ctx, err, "get team") } - // re-generate and register the profile with Apple. Since the profile has been - // updated, then its profile UUID will have been cleared, so this single call - // will do both tasks. - profUUID, _, err := m.DEPService.EnsureCustomSetupAssistantIfExists(ctx, team) - if err != nil { - return ctxerr.Wrap(ctx, err, "ensure custom setup assistant") - } - if profUUID == "" { - // the custom setup assistant profile may have been deleted since the job - // was enqueued, if so another job will take care of assigning the default - // profile to the hosts, nothing to do. - return nil - } - // get the team's mdm-enrolled hosts, assign the profile to all of that // team's hosts serials. serials, err := m.Datastore.ListMDMAppleDEPSerialsInTeam(ctx, args.TeamID) @@ -131,12 +117,25 @@ func (m *MacosSetupAssistant) runProfileChanged(ctx context.Context, args macosS 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") + for orgName, serials := range assignSerials { + profUUID, _, err := m.DEPService.EnsureCustomSetupAssistantIfExists(ctx, team, orgName) + if err != nil { + return ctxerr.Wrapf(ctx, err, "ensure custom setup assistant for ABM org name %q", orgName) + } + if profUUID == "" { + // the custom setup assistant profile may have been deleted since the job + // was enqueued, if so another job will take care of assigning the default + // profile to the hosts, nothing to do. + continue + } + + resp, err := m.DEPClient.AssignProfile(ctx, orgName, 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 profile changed") + } } } return nil @@ -156,11 +155,11 @@ func (m *MacosSetupAssistant) runProfileDeleted(ctx context.Context, args macosS // get the team's setup assistant, to make sure it is still absent. If it is // not, then it was re-created before this job ran, so nothing to do (another // job will take care of assigning the profile to the hosts). - customProfUUID, _, err := m.DEPService.EnsureCustomSetupAssistantIfExists(ctx, team) - if err != nil { - return ctxerr.Wrap(ctx, err, "ensure custom setup assistant") + _, err = m.Datastore.GetMDMAppleSetupAssistant(ctx, args.TeamID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "check if setup assistant exists") } - if customProfUUID != "" { + if !fleet.IsNotFound(err) { // a custom setup assistant was re-created, so nothing to do. return nil } @@ -169,14 +168,6 @@ func (m *MacosSetupAssistant) runProfileDeleted(ctx context.Context, args macosS // of the default profile and assign it to all of the team's hosts. No need // to force a re-generate of the default profile, if it is already registered // with Apple this is fine and we use that profile uuid. - profUUID, _, err := m.DEPService.EnsureDefaultSetupAssistant(ctx, team) - if err != nil { - return ctxerr.Wrap(ctx, err, "ensure default setup assistant") - } - if profUUID == "" { - // this should not happen, return an error - return ctxerr.Errorf(ctx, "default setup assistant profile uuid is empty") - } // get the team's mdm-enrolled hosts, assign the profile to all of that // team's hosts serials. @@ -199,12 +190,23 @@ func (m *MacosSetupAssistant) runProfileDeleted(ctx context.Context, args macosS 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") + for orgName, serials := range assignSerials { + profUUID, _, err := m.DEPService.EnsureDefaultSetupAssistant(ctx, team, orgName) + if err != nil { + return ctxerr.Wrapf(ctx, err, "ensure default setup assistant for ABM organization %q", orgName) + } + if profUUID == "" { + // this should not happen, return an error + return ctxerr.Errorf(ctx, "default setup assistant profile uuid is empty for ABM organization %q", orgName) + } + + resp, err := m.DEPClient.AssignProfile(ctx, orgName, 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 profile deleted") + } } } return nil @@ -227,51 +229,54 @@ func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args maco return ctxerr.Wrap(ctx, err, "get team") } - // get the new team's setup assistant if it exists. - profUUID, _, err := m.DEPService.EnsureCustomSetupAssistantIfExists(ctx, team) + cooldownSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, args.HostSerialNumbers) if err != nil { - return ctxerr.Wrap(ctx, err, "ensure custom setup assistant") - } - if profUUID == "" { - // get the default setup assistant. - defProfUUID, _, err := m.DEPService.EnsureDefaultSetupAssistant(ctx, team) - if err != nil { - return ctxerr.Wrap(ctx, err, "ensure default setup assistant") - } - profUUID = defProfUUID - if profUUID == "" { - // this should not happen, return an error - return ctxerr.Errorf(ctx, "default setup assistant profile uuid is empty") - } + return ctxerr.Wrap(ctx, err, "run hosts transferred") } - 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 it's a retry, serials on cooldown need to be assigned as well. + if fromCooldown { + for k, v := range cooldownSerials { + assignSerials[k] = append(assignSerials[k], v...) } - 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 + } else if len(cooldownSerials) > 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", cooldownSerials)) } - if len(serials) == 0 { + if len(assignSerials) == 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") + for orgName, serials := range assignSerials { + profUUID, _, err := m.DEPService.EnsureCustomSetupAssistantIfExists(ctx, team, orgName) + if err != nil { + return ctxerr.Wrapf(ctx, err, "ensure custom setup assistant for ABM organization %q", orgName) + } + if profUUID == "" { + // get the default setup assistant. + defProfUUID, _, err := m.DEPService.EnsureDefaultSetupAssistant(ctx, team, orgName) + if err != nil { + return ctxerr.Wrapf(ctx, err, "ensure default setup assistant for ABM organization %q", orgName) + } + profUUID = defProfUUID + if profUUID == "" { + // this should not happen, return an error + return ctxerr.Errorf(ctx, "default setup assistant profile uuid is empty for ABM organization %q", orgName) + } + } + + resp, err := m.DEPClient.AssignProfile(ctx, orgName, 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 } @@ -291,6 +296,7 @@ func (m *MacosSetupAssistant) runUpdateAllProfiles(ctx context.Context, args mac 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 } @@ -308,25 +314,31 @@ func (m *MacosSetupAssistant) runUpdateAllProfiles(ctx context.Context, args mac func (m *MacosSetupAssistant) runUpdateProfile(ctx context.Context, args macosSetupAssistantArgs) error { // clear the profile uuid for the default setup assistant for that team/no-team - if err := m.Datastore.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, args.TeamID, ""); err != nil { + if err := m.Datastore.SetMDMAppleDefaultSetupAssistantProfileUUID(ctx, args.TeamID, "", ""); err != nil { return ctxerr.Wrap(ctx, err, "clear default setup assistant profile uuid") } - // clear the profile uuid for the custom setup assistant - if err := m.Datastore.SetMDMAppleSetupAssistantProfileUUID(ctx, args.TeamID, ""); err != nil { - 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 { - return ctxerr.Wrap(ctx, err, "queue macos setup assistant profile deleted job") - } - return nil + // check if there is a custom setup assistant for that team + _, err := m.Datastore.GetMDMAppleSetupAssistant(ctx, args.TeamID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "check if setup assistant exists") + } + 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 { + return ctxerr.Wrap(ctx, err, "queue macos setup assistant profile deleted job") } + return nil + } + + // clear the profile uuid for the custom setup assistant + if err := m.Datastore.SetMDMAppleSetupAssistantProfileUUID(ctx, args.TeamID, "", ""); err != nil { return ctxerr.Wrap(ctx, err, "clear custom setup assistant profile uuid") } - // 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. + // 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 { return ctxerr.Wrap(ctx, err, "queue macos setup assistant profile changed job") } @@ -379,17 +391,17 @@ func QueueMacosSetupAssistantJob( } func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { - serialsByTeamId, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx) + serialsByTeamID, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "getting cooldowns") } - if len(serialsByTeamId) == 0 { + if len(serialsByTeamID) == 0 { level.Info(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 { + for teamID, serials := range serialsByTeamID { if len(serials) == 0 { logger.Log("msg", "no cooldowns", "team_id", teamID) continue diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index 333362460c..f137b418bb 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -28,6 +29,13 @@ func TestMacosSetupAssistant(t *testing.T) { // call TruncateTables immediately as some DB migrations may create jobs mysql.TruncateTables(t, ds) + org1Name := "org1" + org2Name := "org2" + + mysql.SetTestABMAssets(t, ds, "fleet") + tok := mysql.CreateAndSetABMToken(t, ds, org1Name) + tok2 := mysql.CreateAndSetABMToken(t, ds, org2Name) + // create a couple hosts for no team, team 1 and team 2 (none for team 3) hosts := make([]*fleet.Host, 6) for i := 0; i < len(hosts); i++ { @@ -40,10 +48,16 @@ func TestMacosSetupAssistant(t *testing.T) { HardwareSerial: fmt.Sprintf("serial-%d", i), }) require.NoError(t, err) - err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}) + + tokID := tok.ID + if i%2 == 0 { + tokID = tok2.ID + } + + err = ds.UpsertMDMAppleHostDEPAssignments(ctx, []fleet.Host{*h}, tokID) require.NoError(t, err) hosts[i] = h - t.Logf("host [%d]: %s - %s", i, h.UUID, h.HardwareSerial) + t.Logf("host [%d]: %s - %s - %d", i, h.UUID, h.HardwareSerial, tokID) } // create teams @@ -60,8 +74,6 @@ func TestMacosSetupAssistant(t *testing.T) { err = ds.AddHostsToTeam(ctx, &tm2.ID, []uint{hosts[4].ID, hosts[5].ID}) require.NoError(t, err) - mysql.SetTestABMAssets(t, ds) - logger := kitlog.NewNopLogger() depStorage, err := ds.NewMDMAppleDEPStorage() require.NoError(t, err) @@ -126,7 +138,11 @@ func TestMacosSetupAssistant(t *testing.T) { } })) defer srv.Close() - err = depStorage.StoreConfig(ctx, apple_mdm.DEPName, &nanodep_client.Config{BaseURL: srv.URL}) + err = depStorage.StoreConfig(ctx, "fleet", &nanodep_client.Config{BaseURL: srv.URL}) + require.NoError(t, err) + err = depStorage.StoreConfig(ctx, org1Name, &nanodep_client.Config{BaseURL: srv.URL}) + require.NoError(t, err) + err = depStorage.StoreConfig(ctx, org2Name, &nanodep_client.Config{BaseURL: srv.URL}) require.NoError(t, err) w := NewWorker(ds, logger) @@ -160,12 +176,22 @@ func TestMacosSetupAssistant(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, autoProf.Token) - tmIDs := []*uint{nil, ptr.Uint(tm1.ID), ptr.Uint(tm2.ID), ptr.Uint(tm3.ID)} + getTeamID := func(tmID *uint) string { + if tmID == nil { + return "null" + } + + return strconv.Itoa(int(*tmID)) + } + + tmIDs := []*uint{nil, ptr.Uint(tm1.ID), ptr.Uint(tm2.ID)} for _, tmID := range tmIDs { - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID) - require.NoError(t, err) - require.Equal(t, defaultProfileName, profUUID) - require.False(t, modTime.Before(start)) + for _, org := range []string{org1Name, org2Name} { + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, org) + require.NoError(t, err) + require.Equal(t, defaultProfileName, profUUID, "tmID", getTeamID(tmID)) + require.False(t, modTime.Before(start)) + } } require.Equal(t, map[string]string{ "serial-0": defaultProfileName, @@ -190,16 +216,24 @@ func TestMacosSetupAssistant(t *testing.T) { // default profile is unchanged for _, tmID := range tmIDs { - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID) - require.NoError(t, err) - require.Equal(t, defaultProfileName, profUUID) - require.False(t, modTime.Before(start)) + for _, org := range []string{org1Name, org2Name} { + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, org) + require.NoError(t, err) + require.Equal(t, defaultProfileName, profUUID) + require.False(t, modTime.Before(start)) + } } - // team 1 setup assistant is registered + // team 1 setup assistant is registered for both tokens tm1Asst, err = ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID) require.NoError(t, err) - require.Equal(t, "team1", tm1Asst.ProfileUUID) + require.NotNil(t, tm1Asst) + for _, org := range []string{org1Name, org2Name} { + profUUID, modTime, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm1.ID, org) + require.NoError(t, err) + require.Equal(t, "team1", profUUID) + require.False(t, modTime.Before(start)) + } require.Equal(t, map[string]string{ "serial-0": defaultProfileName, @@ -323,20 +357,40 @@ func TestMacosSetupAssistant(t *testing.T) { "serial-5": "no-team", // became a no-team host when team2 got deleted }, serialsToProfile) - // check that profiles get re-generated - reset := time.Now().Truncate(time.Second) - time.Sleep(time.Second) - + // check that profiles get re-generated (note that timestamps are not + // impacted as the content of the profiles did not change) _, err = QueueMacosSetupAssistantJob(ctx, ds, logger, MacosSetupAssistantUpdateAllProfiles, nil) require.NoError(t, err) runCheckDone() // team 2 got deleted, update the list of team IDs tmIDs = []*uint{nil, ptr.Uint(tm1.ID), ptr.Uint(tm3.ID)} - for _, tmID := range tmIDs { - _, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID) - require.NoError(t, err) - require.True(t, modTime.After(reset)) + for i, tmID := range tmIDs { + for _, org := range []string{org1Name, org2Name} { + // no team and team 3 have a custom setup assistant + switch i { + case 0: // no team + // custom profile defined for both orgs + _, _, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, tmID, org) + require.NoError(t, err, "%v - %v", i, org) + case 1: // tm1 + // team 1 uses the default setup assistant, and it is only defined for org1 + _, _, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, org) + if org == org1Name { + require.NoError(t, err, "%v - %v", i, org) + } else { + require.ErrorIs(t, err, sql.ErrNoRows, "%v - %v", i, org) + } + case 2: // tm3 + _, _, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, tmID, org) + // custom setup assistant only defined for org2 + if org == org2Name { + require.NoError(t, err, "%v - %v", i, org) + } else { + require.ErrorIs(t, err, sql.ErrNoRows, "%v - %v", i, org) + } + } + } } require.Equal(t, map[string]string{ diff --git a/terraform/README.md b/terraform/README.md index 76e36faf88..f8015f428c 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -75,9 +75,9 @@ No resources. | [alb\_config](#input\_alb\_config) | n/a |
    object({
    name = optional(string, "fleet")
    security_groups = optional(list(string), [])
    access_logs = optional(map(string), {})
    allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
    allowed_ipv6_cidrs = optional(list(string), ["::/0"])
    egress_cidrs = optional(list(string), ["0.0.0.0/0"])
    egress_ipv6_cidrs = optional(list(string), ["::/0"])
    extra_target_groups = optional(any, [])
    https_listener_rules = optional(any, [])
    tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
    idle_timeout = optional(number, 60)
    })
    | `{}` | no | | [certificate\_arn](#input\_certificate\_arn) | n/a | `string` | n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
    object({
    autoscaling_capacity_providers = optional(any, {})
    cluster_configuration = optional(any, {
    execute_command_configuration = {
    logging = "OVERRIDE"
    log_configuration = {
    cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
    }
    }
    })
    cluster_name = optional(string, "fleet")
    cluster_settings = optional(map(string), {
    "name" : "containerInsights",
    "value" : "enabled",
    })
    create = optional(bool, true)
    default_capacity_provider_use_fargate = optional(bool, true)
    fargate_capacity_providers = optional(any, {
    FARGATE = {
    default_capacity_provider_strategy = {
    weight = 100
    }
    }
    FARGATE_SPOT = {
    default_capacity_provider_strategy = {
    weight = 0
    }
    }
    })
    tags = optional(map(string))
    })
    |
    {
    "autoscaling_capacity_providers": {},
    "cluster_configuration": {
    "execute_command_configuration": {
    "log_configuration": {
    "cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
    },
    "logging": "OVERRIDE"
    }
    },
    "cluster_name": "fleet",
    "cluster_settings": {
    "name": "containerInsights",
    "value": "enabled"
    },
    "create": true,
    "default_capacity_provider_use_fargate": true,
    "fargate_capacity_providers": {
    "FARGATE": {
    "default_capacity_provider_strategy": {
    "weight": 100
    }
    },
    "FARGATE_SPOT": {
    "default_capacity_provider_strategy": {
    "weight": 0
    }
    }
    },
    "tags": {}
    }
    | no | -| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_groups = optional(list(string), null)
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | +| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
    object({
    mem = number
    cpu = number
    })
    |
    {
    "cpu": 1024,
    "mem": 2048
    }
    | no | -| [rds\_config](#input\_rds\_config) | The config for the terraform-aws-modules/rds-aurora/aws module |
    object({
    name = optional(string, "fleet")
    engine_version = optional(string, "8.0.mysql_aurora.3.04.2")
    instance_class = optional(string, "db.t4g.large")
    subnets = optional(list(string), [])
    allowed_security_groups = optional(list(string), [])
    allowed_cidr_blocks = optional(list(string), [])
    apply_immediately = optional(bool, true)
    monitoring_interval = optional(number, 10)
    db_parameter_group_name = optional(string)
    db_parameters = optional(map(string), {})
    db_cluster_parameter_group_name = optional(string)
    db_cluster_parameters = optional(map(string), {})
    enabled_cloudwatch_logs_exports = optional(list(string), [])
    master_username = optional(string, "fleet")
    snapshot_identifier = optional(string)
    cluster_tags = optional(map(string), {})
    })
    |
    {
    "allowed_cidr_blocks": [],
    "allowed_security_groups": [],
    "apply_immediately": true,
    "cluster_tags": {},
    "db_cluster_parameter_group_name": null,
    "db_cluster_parameters": {},
    "db_parameter_group_name": null,
    "db_parameters": {},
    "enabled_cloudwatch_logs_exports": [],
    "engine_version": "8.0.mysql_aurora.3.04.2",
    "instance_class": "db.t4g.large",
    "master_username": "fleet",
    "monitoring_interval": 10,
    "name": "fleet",
    "snapshot_identifier": null,
    "subnets": []
    }
    | no | +| [rds\_config](#input\_rds\_config) | The config for the terraform-aws-modules/rds-aurora/aws module |
    object({
    name = optional(string, "fleet")
    engine_version = optional(string, "8.0.mysql_aurora.3.07.1")
    instance_class = optional(string, "db.t4g.large")
    subnets = optional(list(string), [])
    allowed_security_groups = optional(list(string), [])
    allowed_cidr_blocks = optional(list(string), [])
    apply_immediately = optional(bool, true)
    monitoring_interval = optional(number, 10)
    db_parameter_group_name = optional(string)
    db_parameters = optional(map(string), {})
    db_cluster_parameter_group_name = optional(string)
    db_cluster_parameters = optional(map(string), {})
    enabled_cloudwatch_logs_exports = optional(list(string), [])
    master_username = optional(string, "fleet")
    snapshot_identifier = optional(string)
    cluster_tags = optional(map(string), {})
    })
    |
    {
    "allowed_cidr_blocks": [],
    "allowed_security_groups": [],
    "apply_immediately": true,
    "cluster_tags": {},
    "db_cluster_parameter_group_name": null,
    "db_cluster_parameters": {},
    "db_parameter_group_name": null,
    "db_parameters": {},
    "enabled_cloudwatch_logs_exports": [],
    "engine_version": "8.0.mysql_aurora.3.07.1",
    "instance_class": "db.t4g.large",
    "master_username": "fleet",
    "monitoring_interval": 10,
    "name": "fleet",
    "snapshot_identifier": null,
    "subnets": []
    }
    | no | | [redis\_config](#input\_redis\_config) | n/a |
    object({
    name = optional(string, "fleet")
    replication_group_id = optional(string)
    elasticache_subnet_group_name = optional(string)
    allowed_security_group_ids = optional(list(string), [])
    subnets = optional(list(string))
    availability_zones = optional(list(string))
    cluster_size = optional(number, 3)
    instance_type = optional(string, "cache.m5.large")
    apply_immediately = optional(bool, true)
    automatic_failover_enabled = optional(bool, false)
    engine_version = optional(string, "6.x")
    family = optional(string, "redis6.x")
    at_rest_encryption_enabled = optional(bool, true)
    transit_encryption_enabled = optional(bool, true)
    parameter = optional(list(object({
    name = string
    value = string
    })), [])
    log_delivery_configuration = optional(list(map(any)), [])
    tags = optional(map(string), {})
    })
    |
    {
    "allowed_security_group_ids": [],
    "apply_immediately": true,
    "at_rest_encryption_enabled": true,
    "automatic_failover_enabled": false,
    "availability_zones": null,
    "cluster_size": 3,
    "elasticache_subnet_group_name": null,
    "engine_version": "6.x",
    "family": "redis6.x",
    "instance_type": "cache.m5.large",
    "log_delivery_configuration": [],
    "name": "fleet",
    "parameter": [],
    "replication_group_id": null,
    "subnets": null,
    "tags": {},
    "transit_encryption_enabled": true
    }
    | no | | [vpc](#input\_vpc) | n/a |
    object({
    name = optional(string, "fleet")
    cidr = optional(string, "10.10.0.0/16")
    azs = optional(list(string), ["us-east-2a", "us-east-2b", "us-east-2c"])
    private_subnets = optional(list(string), ["10.10.1.0/24", "10.10.2.0/24", "10.10.3.0/24"])
    public_subnets = optional(list(string), ["10.10.11.0/24", "10.10.12.0/24", "10.10.13.0/24"])
    database_subnets = optional(list(string), ["10.10.21.0/24", "10.10.22.0/24", "10.10.23.0/24"])
    elasticache_subnets = optional(list(string), ["10.10.31.0/24", "10.10.32.0/24", "10.10.33.0/24"])

    create_database_subnet_group = optional(bool, false)
    create_database_subnet_route_table = optional(bool, true)
    create_elasticache_subnet_group = optional(bool, true)
    create_elasticache_subnet_route_table = optional(bool, true)
    enable_vpn_gateway = optional(bool, false)
    one_nat_gateway_per_az = optional(bool, false)
    single_nat_gateway = optional(bool, true)
    enable_nat_gateway = optional(bool, true)
    enable_dns_hostnames = optional(bool, false)
    enable_dns_support = optional(bool, true)
    enable_flow_log = optional(bool, false)
    create_flow_log_cloudwatch_log_group = optional(bool, false)
    create_flow_log_cloudwatch_iam_role = optional(bool, false)
    flow_log_max_aggregation_interval = optional(number, 600)
    flow_log_cloudwatch_log_group_name_prefix = optional(string, "/aws/vpc-flow-log/")
    flow_log_cloudwatch_log_group_name_suffix = optional(string, "")
    vpc_flow_log_tags = optional(map(string), {})
    })
    |
    {
    "azs": [
    "us-east-2a",
    "us-east-2b",
    "us-east-2c"
    ],
    "cidr": "10.10.0.0/16",
    "create_database_subnet_group": false,
    "create_database_subnet_route_table": true,
    "create_elasticache_subnet_group": true,
    "create_elasticache_subnet_route_table": true,
    "create_flow_log_cloudwatch_iam_role": false,
    "create_flow_log_cloudwatch_log_group": false,
    "database_subnets": [
    "10.10.21.0/24",
    "10.10.22.0/24",
    "10.10.23.0/24"
    ],
    "elasticache_subnets": [
    "10.10.31.0/24",
    "10.10.32.0/24",
    "10.10.33.0/24"
    ],
    "enable_dns_hostnames": false,
    "enable_dns_support": true,
    "enable_flow_log": false,
    "enable_nat_gateway": true,
    "enable_vpn_gateway": false,
    "flow_log_cloudwatch_log_group_name_prefix": "/aws/vpc-flow-log/",
    "flow_log_cloudwatch_log_group_name_suffix": "",
    "flow_log_max_aggregation_interval": 600,
    "name": "fleet",
    "one_nat_gateway_per_az": false,
    "private_subnets": [
    "10.10.1.0/24",
    "10.10.2.0/24",
    "10.10.3.0/24"
    ],
    "public_subnets": [
    "10.10.11.0/24",
    "10.10.12.0/24",
    "10.10.13.0/24"
    ],
    "single_nat_gateway": true,
    "vpc_flow_log_tags": {}
    }
    | no | diff --git a/terraform/addons/mdm/README.md b/terraform/addons/mdm/README.md index 0cb04aac59..4acccf3d9b 100644 --- a/terraform/addons/mdm/README.md +++ b/terraform/addons/mdm/README.md @@ -1,7 +1,7 @@ # MDM addon Notice: Previous versions of this module referred to `dep`, but to reduce confusion that has been replaces with `abm` -to mach the change to the newer Apple Busines Manager. For each key/value pair below, the key names have been changed +to mach the change to the newer Apple Business Manager. For each key/value pair below, the key names have been changed from previous version to match the name of the env var for easier usability. Older unused env vars were also removed for simplification. This includes removing the need for `extra_environment_variables` completely. diff --git a/terraform/addons/mdmproxy/main.tf b/terraform/addons/mdmproxy/main.tf index 4e57a0dcfe..5d4a7ca51a 100644 --- a/terraform/addons/mdmproxy/main.tf +++ b/terraform/addons/mdmproxy/main.tf @@ -140,6 +140,11 @@ resource "aws_ecs_service" "mdmproxy" { desired_count = var.config.desired_count deployment_minimum_healthy_percent = 100 deployment_maximum_percent = 200 + force_new_deployment = true + + triggers = { + redeployment = md5(jsonencode(aws_secretsmanager_secret_version.mdmproxy.secret_string)) + } load_balancer { target_group_arn = module.alb.target_group_arns[0] diff --git a/terraform/addons/monitoring/lambda/go.mod b/terraform/addons/monitoring/lambda/go.mod index 5e7c002f41..bd2389b8ef 100644 --- a/terraform/addons/monitoring/lambda/go.mod +++ b/terraform/addons/monitoring/lambda/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/terraform/addons/monitoring/lambda -go 1.22.4 +go 1.23.1 require ( github.com/aws/aws-lambda-go v1.41.0 diff --git a/terraform/addons/saml-auth-proxy/README.md b/terraform/addons/saml-auth-proxy/README.md index cae388b2b3..baaa39bac4 100644 --- a/terraform/addons/saml-auth-proxy/README.md +++ b/terraform/addons/saml-auth-proxy/README.md @@ -32,6 +32,7 @@ No requirements. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| +| [alb\_access\_logs](#input\_alb\_access\_logs) | n/a | `map(string)` | `{}` | no | | [alb\_target\_group\_arn](#input\_alb\_target\_group\_arn) | n/a | `string` | n/a | yes | | [base\_url](#input\_base\_url) | n/a | `string` | n/a | yes | | [cookie\_max\_age](#input\_cookie\_max\_age) | n/a | `string` | `"1h"` | no | @@ -53,6 +54,7 @@ No requirements. |------|-------------| | [fleet\_extra\_execution\_policies](#output\_fleet\_extra\_execution\_policies) | n/a | | [lb](#output\_lb) | n/a | +| [lb\_security\_group](#output\_lb\_security\_group) | n/a | | [lb\_target\_group\_arn](#output\_lb\_target\_group\_arn) | Keep for legacy support for now | | [name](#output\_name) | n/a | | [secretsmanager\_secret\_id](#output\_secretsmanager\_secret\_id) | n/a | diff --git a/terraform/addons/saml-auth-proxy/main.tf b/terraform/addons/saml-auth-proxy/main.tf index 2148e41c4f..6daa975d44 100644 --- a/terraform/addons/saml-auth-proxy/main.tf +++ b/terraform/addons/saml-auth-proxy/main.tf @@ -82,7 +82,7 @@ module "saml_auth_proxy_alb" { subnets = var.subnets security_groups = [aws_security_group.saml_auth_proxy_alb.id] # FIXME: Get this working eventually. - # access_logs = var.alb_config.access_logs + access_logs = var.alb_access_logs internal = true target_groups = [ diff --git a/terraform/addons/saml-auth-proxy/outputs.tf b/terraform/addons/saml-auth-proxy/outputs.tf index cea09cf5b3..afc268f9c8 100644 --- a/terraform/addons/saml-auth-proxy/outputs.tf +++ b/terraform/addons/saml-auth-proxy/outputs.tf @@ -17,6 +17,10 @@ output "lb" { value = module.saml_auth_proxy_alb } +output "lb_security_group" { + value = aws_security_group.saml_auth_proxy_alb.id +} + output "secretsmanager_secret_id" { value = aws_secretsmanager_secret.saml_auth_proxy_cert.id } diff --git a/terraform/addons/saml-auth-proxy/variables.tf b/terraform/addons/saml-auth-proxy/variables.tf index 66aa6677d7..f441c643e3 100644 --- a/terraform/addons/saml-auth-proxy/variables.tf +++ b/terraform/addons/saml-auth-proxy/variables.tf @@ -7,6 +7,11 @@ variable "alb_target_group_arn" { type = string } +variable "alb_access_logs" { + type = map(string) + default = {} +} + # variable "public_alb_security_group_id" { # type = string # } diff --git a/terraform/addons/ses/main.tf b/terraform/addons/ses/main.tf index 6c7304f1ba..00aec19fe0 100644 --- a/terraform/addons/ses/main.tf +++ b/terraform/addons/ses/main.tf @@ -1,3 +1,10 @@ +locals { + spf_domains = [ + aws_ses_domain_identity.default.domain, + "_amazonses.${aws_ses_domain_identity.default.domain}" + ] +} + resource "aws_ses_domain_identity" "default" { domain = var.domain } @@ -19,11 +26,12 @@ resource "aws_route53_record" "amazonses_dkim_record" { resource "aws_route53_record" "spf_domain" { - zone_id = var.zone_id - name = "_amazonses.${aws_ses_domain_identity.default.domain}" - type = "TXT" - ttl = "600" - records = ["v=spf1 include:amazonses.com -all"] + for_each = toset(local.spf_domains) + zone_id = var.zone_id + name = each.key + type = "TXT" + ttl = "600" + records = ["v=spf1 include:amazonses.com -all"] } resource "aws_iam_policy" "main" { diff --git a/terraform/addons/ses/variables.tf b/terraform/addons/ses/variables.tf index be93f34ccf..a174e55f0a 100644 --- a/terraform/addons/ses/variables.tf +++ b/terraform/addons/ses/variables.tf @@ -4,6 +4,6 @@ variable "domain" { } variable "zone_id" { - type = string + type = string description = "Route53 Zone ID" } diff --git a/terraform/addons/vuln-processing/variables.tf b/terraform/addons/vuln-processing/variables.tf index eaea7243fa..feb850667d 100644 --- a/terraform/addons/vuln-processing/variables.tf +++ b/terraform/addons/vuln-processing/variables.tf @@ -24,7 +24,7 @@ variable "fleet_config" { vuln_processing_cpu = optional(number, 2048) vuln_data_stream_mem = optional(number, 1024) vuln_data_stream_cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.54.1") + image = optional(string, "fleetdm/fleet:v4.56.0") family = optional(string, "fleet-vuln-processing") sidecars = optional(list(any), []) extra_environment_variables = optional(map(string), {}) @@ -82,7 +82,7 @@ variable "fleet_config" { vuln_processing_cpu = 2048 vuln_data_stream_mem = 1024 vuln_data_stream_cpu = 512 - image = "fleetdm/fleet:v4.54.1" + image = "fleetdm/fleet:v4.56.0" family = "fleet-vuln-processing" sidecars = [] extra_environment_variables = {} diff --git a/terraform/addons/waf-alb/main.tf b/terraform/addons/waf-alb/main.tf index 01099156cd..a62daf3e1b 100644 --- a/terraform/addons/waf-alb/main.tf +++ b/terraform/addons/waf-alb/main.tf @@ -1,4 +1,9 @@ -resource "aws_wafv2_rule_group" "main" { +locals { + default_action = var.waf_type == "blocklist" ? "block" : "allow" +} + +resource "aws_wafv2_rule_group" "blocked" { + count = var.waf_type == "blocklist" ? 1 : 0 name = var.name scope = "REGIONAL" capacity = 2 @@ -34,7 +39,7 @@ resource "aws_wafv2_rule_group" "main" { statement { ip_set_reference_statement { - arn = aws_wafv2_ip_set.main.arn + arn = aws_wafv2_ip_set.blocked[0].arn } } @@ -52,19 +57,61 @@ resource "aws_wafv2_rule_group" "main" { } } -resource "aws_wafv2_ip_set" "main" { +resource "aws_wafv2_ip_set" "blocked" { + count = var.waf_type == "blocklist" ? 1 : 0 name = var.name scope = "REGIONAL" ip_address_version = "IPV4" addresses = var.blocked_addresses } +resource "aws_wafv2_rule_group" "allowed" { + count = var.waf_type == "allowlist" ? 1 : 0 + name = var.name + scope = "REGIONAL" + capacity = 2 + + rule { + name = "specific" + priority = 1 + + action { + allow {} + } + + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.allowed[0].arn + } + } + + visibility_config { + cloudwatch_metrics_enabled = false + metric_name = var.name + sampled_requests_enabled = false + } + } + + visibility_config { + cloudwatch_metrics_enabled = false + metric_name = var.name + sampled_requests_enabled = false + } +} + resource "aws_wafv2_web_acl" "main" { name = var.name scope = "REGIONAL" default_action { - allow {} + dynamic "block" { + for_each = var.waf_type == "allowlist" ? [true] : [] + content {} + } + dynamic "allow" { + for_each = var.waf_type == "blocklist" ? [true] : [] + content {} + } } rule { @@ -77,7 +124,7 @@ resource "aws_wafv2_web_acl" "main" { statement { rule_group_reference_statement { - arn = aws_wafv2_rule_group.main.arn + arn = var.waf_type == "blocklist" ? aws_wafv2_rule_group.blocked[0].arn : aws_wafv2_rule_group.allowed[0].arn } } @@ -95,6 +142,15 @@ resource "aws_wafv2_web_acl" "main" { } } +resource "aws_wafv2_ip_set" "allowed" { + count = var.waf_type == "allowlist" ? 1 : 0 + name = var.name + scope = "REGIONAL" + ip_address_version = "IPV4" + addresses = var.allowed_addresses +} + + resource "aws_wafv2_web_acl_association" "main" { resource_arn = var.lb_arn web_acl_arn = aws_wafv2_web_acl.main.arn diff --git a/terraform/addons/waf-alb/variables.tf b/terraform/addons/waf-alb/variables.tf index 4afd23e80e..2c16acc653 100644 --- a/terraform/addons/waf-alb/variables.tf +++ b/terraform/addons/waf-alb/variables.tf @@ -2,6 +2,11 @@ variable "name" {} variable "lb_arn" {} +variable "waf_type" { + type = string + default = "blocklist" +} + variable "blocked_countries" { type = list(string) default = ["BI", "BY", "CD", "CF", "CU", "IQ", "IR", "LB", "LY", "SD", "SO", "SS", "SY", "VE", "ZW", "RU"] @@ -11,3 +16,8 @@ variable "blocked_addresses" { type = list(string) default = [] } + +variable "allowed_addresses" { + type = list(string) + default = [] +} diff --git a/terraform/byo-vpc/README.md b/terraform/byo-vpc/README.md index 06dd43631b..16998b14b1 100644 --- a/terraform/byo-vpc/README.md +++ b/terraform/byo-vpc/README.md @@ -33,9 +33,9 @@ No requirements. |------|-------------|------|---------|:--------:| | [alb\_config](#input\_alb\_config) | n/a |
    object({
    name = optional(string, "fleet")
    subnets = list(string)
    security_groups = optional(list(string), [])
    access_logs = optional(map(string), {})
    certificate_arn = string
    allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
    allowed_ipv6_cidrs = optional(list(string), ["::/0"])
    egress_cidrs = optional(list(string), ["0.0.0.0/0"])
    egress_ipv6_cidrs = optional(list(string), ["::/0"])
    extra_target_groups = optional(any, [])
    https_listener_rules = optional(any, [])
    tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
    idle_timeout = optional(number, 60)
    })
    | n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
    object({
    autoscaling_capacity_providers = optional(any, {})
    cluster_configuration = optional(any, {
    execute_command_configuration = {
    logging = "OVERRIDE"
    log_configuration = {
    cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
    }
    }
    })
    cluster_name = optional(string, "fleet")
    cluster_settings = optional(map(string), {
    "name" : "containerInsights",
    "value" : "enabled",
    })
    create = optional(bool, true)
    default_capacity_provider_use_fargate = optional(bool, true)
    fargate_capacity_providers = optional(any, {
    FARGATE = {
    default_capacity_provider_strategy = {
    weight = 100
    }
    }
    FARGATE_SPOT = {
    default_capacity_provider_strategy = {
    weight = 0
    }
    }
    })
    tags = optional(map(string))
    })
    |
    {
    "autoscaling_capacity_providers": {},
    "cluster_configuration": {
    "execute_command_configuration": {
    "log_configuration": {
    "cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
    },
    "logging": "OVERRIDE"
    }
    },
    "cluster_name": "fleet",
    "cluster_settings": {
    "name": "containerInsights",
    "value": "enabled"
    },
    "create": true,
    "default_capacity_provider_use_fargate": true,
    "fargate_capacity_providers": {
    "FARGATE": {
    "default_capacity_provider_strategy": {
    "weight": 100
    }
    },
    "FARGATE_SPOT": {
    "default_capacity_provider_strategy": {
    "weight": 0
    }
    }
    },
    "tags": {}
    }
    | no | -| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_groups = optional(list(string), null)
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | +| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
    object({
    mem = number
    cpu = number
    })
    |
    {
    "cpu": 1024,
    "mem": 2048
    }
    | no | -| [rds\_config](#input\_rds\_config) | The config for the terraform-aws-modules/rds-aurora/aws module |
    object({
    name = optional(string, "fleet")
    engine_version = optional(string, "8.0.mysql_aurora.3.04.2")
    instance_class = optional(string, "db.t4g.large")
    subnets = optional(list(string), [])
    allowed_security_groups = optional(list(string), [])
    allowed_cidr_blocks = optional(list(string), [])
    apply_immediately = optional(bool, true)
    monitoring_interval = optional(number, 10)
    db_parameter_group_name = optional(string)
    db_parameters = optional(map(string), {})
    db_cluster_parameter_group_name = optional(string)
    db_cluster_parameters = optional(map(string), {})
    enabled_cloudwatch_logs_exports = optional(list(string), [])
    master_username = optional(string, "fleet")
    snapshot_identifier = optional(string)
    cluster_tags = optional(map(string), {})
    preferred_maintenance_window = optional(string, "thu:23:00-fri:00:00")
    })
    |
    {
    "allowed_cidr_blocks": [],
    "allowed_security_groups": [],
    "apply_immediately": true,
    "cluster_tags": {},
    "db_cluster_parameter_group_name": null,
    "db_cluster_parameters": {},
    "db_parameter_group_name": null,
    "db_parameters": {},
    "enabled_cloudwatch_logs_exports": [],
    "engine_version": "8.0.mysql_aurora.3.04.2",
    "instance_class": "db.t4g.large",
    "master_username": "fleet",
    "monitoring_interval": 10,
    "name": "fleet",
    "preferred_maintenance_window": "thu:23:00-fri:00:00",
    "snapshot_identifier": null,
    "subnets": []
    }
    | no | +| [rds\_config](#input\_rds\_config) | The config for the terraform-aws-modules/rds-aurora/aws module |
    object({
    name = optional(string, "fleet")
    engine_version = optional(string, "8.0.mysql_aurora.3.07.1")
    instance_class = optional(string, "db.t4g.large")
    subnets = optional(list(string), [])
    allowed_security_groups = optional(list(string), [])
    allowed_cidr_blocks = optional(list(string), [])
    apply_immediately = optional(bool, true)
    monitoring_interval = optional(number, 10)
    db_parameter_group_name = optional(string)
    db_parameters = optional(map(string), {})
    db_cluster_parameter_group_name = optional(string)
    db_cluster_parameters = optional(map(string), {})
    enabled_cloudwatch_logs_exports = optional(list(string), [])
    master_username = optional(string, "fleet")
    snapshot_identifier = optional(string)
    cluster_tags = optional(map(string), {})
    preferred_maintenance_window = optional(string, "thu:23:00-fri:00:00")
    })
    |
    {
    "allowed_cidr_blocks": [],
    "allowed_security_groups": [],
    "apply_immediately": true,
    "cluster_tags": {},
    "db_cluster_parameter_group_name": null,
    "db_cluster_parameters": {},
    "db_parameter_group_name": null,
    "db_parameters": {},
    "enabled_cloudwatch_logs_exports": [],
    "engine_version": "8.0.mysql_aurora.3.07.1",
    "instance_class": "db.t4g.large",
    "master_username": "fleet",
    "monitoring_interval": 10,
    "name": "fleet",
    "preferred_maintenance_window": "thu:23:00-fri:00:00",
    "snapshot_identifier": null,
    "subnets": []
    }
    | no | | [redis\_config](#input\_redis\_config) | n/a |
    object({
    name = optional(string, "fleet")
    replication_group_id = optional(string)
    elasticache_subnet_group_name = optional(string, "")
    allowed_security_group_ids = optional(list(string), [])
    subnets = list(string)
    allowed_cidrs = list(string)
    availability_zones = optional(list(string), [])
    cluster_size = optional(number, 3)
    instance_type = optional(string, "cache.m5.large")
    apply_immediately = optional(bool, true)
    automatic_failover_enabled = optional(bool, false)
    engine_version = optional(string, "6.x")
    family = optional(string, "redis6.x")
    at_rest_encryption_enabled = optional(bool, true)
    transit_encryption_enabled = optional(bool, true)
    parameter = optional(list(object({
    name = string
    value = string
    })), [])
    log_delivery_configuration = optional(list(map(any)), [])
    tags = optional(map(string), {})
    })
    |
    {
    "allowed_cidrs": null,
    "allowed_security_group_ids": [],
    "apply_immediately": true,
    "at_rest_encryption_enabled": true,
    "automatic_failover_enabled": false,
    "availability_zones": [],
    "cluster_size": 3,
    "elasticache_subnet_group_name": "",
    "engine_version": "6.x",
    "family": "redis6.x",
    "instance_type": "cache.m5.large",
    "log_delivery_configuration": [],
    "name": "fleet",
    "parameter": [],
    "replication_group_id": null,
    "subnets": null,
    "tags": {},
    "transit_encryption_enabled": true
    }
    | no | | [vpc\_config](#input\_vpc\_config) | n/a |
    object({
    vpc_id = string
    networking = object({
    subnets = list(string)
    })
    })
    | n/a | yes | diff --git a/terraform/byo-vpc/byo-db/README.md b/terraform/byo-vpc/byo-db/README.md index ef98aec823..60d1444489 100644 --- a/terraform/byo-vpc/byo-db/README.md +++ b/terraform/byo-vpc/byo-db/README.md @@ -28,7 +28,7 @@ No requirements. |------|-------------|------|---------|:--------:| | [alb\_config](#input\_alb\_config) | n/a |
    object({
    name = optional(string, "fleet")
    subnets = list(string)
    security_groups = optional(list(string), [])
    access_logs = optional(map(string), {})
    certificate_arn = string
    allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
    allowed_ipv6_cidrs = optional(list(string), ["::/0"])
    egress_cidrs = optional(list(string), ["0.0.0.0/0"])
    egress_ipv6_cidrs = optional(list(string), ["::/0"])
    extra_target_groups = optional(any, [])
    https_listener_rules = optional(any, [])
    tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
    idle_timeout = optional(number, 60)
    })
    | n/a | yes | | [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module |
    object({
    autoscaling_capacity_providers = optional(any, {})
    cluster_configuration = optional(any, {
    execute_command_configuration = {
    logging = "OVERRIDE"
    log_configuration = {
    cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
    }
    }
    })
    cluster_name = optional(string, "fleet")
    cluster_settings = optional(map(string), {
    "name" : "containerInsights",
    "value" : "enabled",
    })
    create = optional(bool, true)
    default_capacity_provider_use_fargate = optional(bool, true)
    fargate_capacity_providers = optional(any, {
    FARGATE = {
    default_capacity_provider_strategy = {
    weight = 100
    }
    }
    FARGATE_SPOT = {
    default_capacity_provider_strategy = {
    weight = 0
    }
    }
    })
    tags = optional(map(string))
    })
    |
    {
    "autoscaling_capacity_providers": {},
    "cluster_configuration": {
    "execute_command_configuration": {
    "log_configuration": {
    "cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
    },
    "logging": "OVERRIDE"
    }
    },
    "cluster_name": "fleet",
    "cluster_settings": {
    "name": "containerInsights",
    "value": "enabled"
    },
    "create": true,
    "default_capacity_provider_use_fargate": true,
    "fargate_capacity_providers": {
    "FARGATE": {
    "default_capacity_provider_strategy": {
    "weight": 100
    }
    },
    "FARGATE_SPOT": {
    "default_capacity_provider_strategy": {
    "weight": 0
    }
    }
    },
    "tags": {}
    }
    | no | -| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_groups = optional(list(string), null)
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | +| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = optional(object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    }), {
    password_secret_arn = null
    user = null
    database = null
    address = null
    rr_address = null
    })
    redis = optional(object({
    address = string
    use_tls = optional(bool, true)
    }), {
    address = null
    use_tls = true
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = optional(object({
    arn = string
    }), {
    arn = null
    })
    extra_load_balancers = optional(list(any), [])
    networking = optional(object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = optional(object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    }), {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    })
    }), {
    subnets = null
    security_groups = null
    ingress_sources = {
    cidr_blocks = []
    ipv6_cidr_blocks = []
    security_groups = []
    prefix_list_ids = []
    }
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balancers": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
    object({
    mem = number
    cpu = number
    })
    |
    {
    "cpu": 1024,
    "mem": 2048
    }
    | no | | [vpc\_id](#input\_vpc\_id) | n/a | `string` | n/a | yes | diff --git a/terraform/byo-vpc/byo-db/byo-ecs/README.md b/terraform/byo-vpc/byo-db/byo-ecs/README.md index ac2a39774f..fc701fb07c 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/README.md +++ b/terraform/byo-vpc/byo-db/byo-ecs/README.md @@ -52,7 +52,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [ecs\_cluster](#input\_ecs\_cluster) | The name of the ECS cluster to use | `string` | n/a | yes | -| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_groups = optional(list(string), null)
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    })
    redis = object({
    address = string
    use_tls = optional(bool, true)
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = object({
    arn = string
    })
    extra_load_balancers = optional(list(any), [])
    networking = object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    })
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balacners": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | +| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. |
    object({
    task_mem = optional(number, null)
    task_cpu = optional(number, null)
    mem = optional(number, 4096)
    cpu = optional(number, 512)
    pid_mode = optional(string, null)
    image = optional(string, "fleetdm/fleet:v4.54.1")
    family = optional(string, "fleet")
    sidecars = optional(list(any), [])
    depends_on = optional(list(any), [])
    mount_points = optional(list(any), [])
    volumes = optional(list(any), [])
    extra_environment_variables = optional(map(string), {})
    extra_iam_policies = optional(list(string), [])
    extra_execution_iam_policies = optional(list(string), [])
    extra_secrets = optional(map(string), {})
    security_group_name = optional(string, "fleet")
    iam_role_arn = optional(string, null)
    repository_credentials = optional(string, "")
    private_key_secret_name = optional(string, "fleet-server-private-key")
    service = optional(object({
    name = optional(string, "fleet")
    }), {
    name = "fleet"
    })
    database = object({
    password_secret_arn = string
    user = string
    database = string
    address = string
    rr_address = optional(string, null)
    })
    redis = object({
    address = string
    use_tls = optional(bool, true)
    })
    awslogs = optional(object({
    name = optional(string, null)
    region = optional(string, null)
    create = optional(bool, true)
    prefix = optional(string, "fleet")
    retention = optional(number, 5)
    }), {
    name = null
    region = null
    prefix = "fleet"
    retention = 5
    })
    loadbalancer = object({
    arn = string
    })
    extra_load_balancers = optional(list(any), [])
    networking = object({
    subnets = optional(list(string), null)
    security_groups = optional(list(string), null)
    ingress_sources = object({
    cidr_blocks = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups = optional(list(string), [])
    prefix_list_ids = optional(list(string), [])
    })
    })
    autoscaling = optional(object({
    max_capacity = optional(number, 5)
    min_capacity = optional(number, 1)
    memory_tracking_target_value = optional(number, 80)
    cpu_tracking_target_value = optional(number, 80)
    }), {
    max_capacity = 5
    min_capacity = 1
    memory_tracking_target_value = 80
    cpu_tracking_target_value = 80
    })
    iam = optional(object({
    role = optional(object({
    name = optional(string, "fleet-role")
    policy_name = optional(string, "fleet-iam-policy")
    }), {
    name = "fleet-role"
    policy_name = "fleet-iam-policy"
    })
    execution = optional(object({
    name = optional(string, "fleet-execution-role")
    policy_name = optional(string, "fleet-execution-role")
    }), {
    name = "fleet-execution-role"
    policy_name = "fleet-iam-policy-execution"
    })
    }), {
    name = "fleetdm-execution-role"
    })
    software_installers = optional(object({
    create_bucket = optional(bool, true)
    bucket_name = optional(string, null)
    bucket_prefix = optional(string, "fleet-software-installers-")
    s3_object_prefix = optional(string, "")
    }), {
    create_bucket = true
    bucket_name = null
    bucket_prefix = "fleet-software-installers-"
    s3_object_prefix = ""
    })
    })
    |
    {
    "autoscaling": {
    "cpu_tracking_target_value": 80,
    "max_capacity": 5,
    "memory_tracking_target_value": 80,
    "min_capacity": 1
    },
    "awslogs": {
    "create": true,
    "name": null,
    "prefix": "fleet",
    "region": null,
    "retention": 5
    },
    "cpu": 256,
    "database": {
    "address": null,
    "database": null,
    "password_secret_arn": null,
    "rr_address": null,
    "user": null
    },
    "depends_on": [],
    "extra_environment_variables": {},
    "extra_execution_iam_policies": [],
    "extra_iam_policies": [],
    "extra_load_balacners": [],
    "extra_secrets": {},
    "family": "fleet",
    "iam": {
    "execution": {
    "name": "fleet-execution-role",
    "policy_name": "fleet-iam-policy-execution"
    },
    "role": {
    "name": "fleet-role",
    "policy_name": "fleet-iam-policy"
    }
    },
    "iam_role_arn": null,
    "image": "fleetdm/fleet:v4.54.1",
    "loadbalancer": {
    "arn": null
    },
    "mem": 512,
    "mount_points": [],
    "networking": {
    "ingress_sources": {
    "cidr_blocks": [],
    "ipv6_cidr_blocks": [],
    "prefix_list_ids": [],
    "security_groups": []
    },
    "security_groups": null,
    "subnets": null
    },
    "pid_mode": null,
    "private_key_secret_name": "fleet-server-private-key",
    "redis": {
    "address": null,
    "use_tls": true
    },
    "repository_credentials": "",
    "security_group_name": "fleet",
    "security_groups": null,
    "service": {
    "name": "fleet"
    },
    "sidecars": [],
    "software_installers": {
    "bucket_name": null,
    "bucket_prefix": "fleet-software-installers-",
    "create_bucket": true,
    "s3_object_prefix": ""
    },
    "task_cpu": null,
    "task_mem": null,
    "volumes": []
    }
    | no | | [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. |
    object({
    mem = number
    cpu = number
    })
    |
    {
    "cpu": 1024,
    "mem": 2048
    }
    | no | | [vpc\_id](#input\_vpc\_id) | n/a | `string` | `null` | no | diff --git a/terraform/byo-vpc/byo-db/byo-ecs/main.tf b/terraform/byo-vpc/byo-db/byo-ecs/main.tf index 3d2fb7191d..ab56fe80fa 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/main.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/main.tf @@ -232,7 +232,7 @@ resource "aws_cloudwatch_log_group" "main" { #tfsec:ignore:aws-cloudwatch-log-gr } resource "aws_security_group" "main" { - count = var.fleet_config.security_groups == null ? 1 : 0 + count = var.fleet_config.networking.security_groups == null ? 1 : 0 name = var.fleet_config.security_group_name description = "Fleet ECS Service Security Group" vpc_id = var.vpc_id diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index 44ca11ffc1..0270c8fb52 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -16,7 +16,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.54.1") + image = optional(string, "fleetdm/fleet:v4.56.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -26,7 +26,6 @@ variable "fleet_config" { extra_iam_policies = optional(list(string), []) extra_execution_iam_policies = optional(list(string), []) extra_secrets = optional(map(string), {}) - security_groups = optional(list(string), null) security_group_name = optional(string, "fleet") iam_role_arn = optional(string, null) repository_credentials = optional(string, "") @@ -120,7 +119,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.54.1" + image = "fleetdm/fleet:v4.56.0" family = "fleet" sidecars = [] depends_on = [] @@ -130,7 +129,6 @@ variable "fleet_config" { extra_iam_policies = [] extra_execution_iam_policies = [] extra_secrets = {} - security_groups = null security_group_name = "fleet" iam_role_arn = null repository_credentials = "" diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index 37bca0a2af..0044e48e5c 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -77,7 +77,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.54.1") + image = optional(string, "fleetdm/fleet:v4.56.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -87,7 +87,6 @@ variable "fleet_config" { extra_iam_policies = optional(list(string), []) extra_execution_iam_policies = optional(list(string), []) extra_secrets = optional(map(string), {}) - security_groups = optional(list(string), null) security_group_name = optional(string, "fleet") iam_role_arn = optional(string, null) repository_credentials = optional(string, "") @@ -206,7 +205,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.54.1" + image = "fleetdm/fleet:v4.56.0" family = "fleet" sidecars = [] depends_on = [] diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index 4a34edd6f3..887b907b30 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.54.1" + fleet_image = "fleetdm/fleet:v4.56.0" domain_name = "example.com" } diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index d53ddedd6d..cba22bf845 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -10,7 +10,7 @@ variable "vpc_config" { variable "rds_config" { type = object({ name = optional(string, "fleet") - engine_version = optional(string, "8.0.mysql_aurora.3.04.2") + engine_version = optional(string, "8.0.mysql_aurora.3.07.1") instance_class = optional(string, "db.t4g.large") subnets = optional(list(string), []) allowed_security_groups = optional(list(string), []) @@ -29,7 +29,7 @@ variable "rds_config" { }) default = { name = "fleet" - engine_version = "8.0.mysql_aurora.3.04.2" + engine_version = "8.0.mysql_aurora.3.07.1" instance_class = "db.t4g.large" subnets = [] allowed_security_groups = [] @@ -170,7 +170,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.54.1") + image = optional(string, "fleetdm/fleet:v4.56.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -180,7 +180,6 @@ variable "fleet_config" { extra_iam_policies = optional(list(string), []) extra_execution_iam_policies = optional(list(string), []) extra_secrets = optional(map(string), {}) - security_groups = optional(list(string), null) security_group_name = optional(string, "fleet") iam_role_arn = optional(string, null) repository_credentials = optional(string, "") @@ -299,7 +298,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.54.1" + image = "fleetdm/fleet:v4.56.0" family = "fleet" sidecars = [] depends_on = [] diff --git a/terraform/example/main.tf b/terraform/example/main.tf index cb425779ba..33b6f5221e 100644 --- a/terraform/example/main.tf +++ b/terraform/example/main.tf @@ -50,7 +50,7 @@ locals { } module "fleet" { - source = "github.com/fleetdm/fleet//terraform?ref=tf-mod-root-v1.10.0" + source = "github.com/fleetdm/fleet//terraform?ref=tf-mod-root-v1.11.1" certificate_arn = module.acm.acm_certificate_arn vpc = { @@ -63,8 +63,8 @@ module "fleet" { fleet_config = { # To avoid pull-rate limiting from dockerhub, consider using our quay.io mirror - # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.54.1" - image = "fleetdm/fleet:v4.54.1" # override default to deploy the image you desire + # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.56.0" + image = "fleetdm/fleet:v4.56.0" # override default to deploy the image you desire # See https://fleetdm.com/docs/deploy/reference-architectures#aws for appropriate scaling # memory and cpu. autoscaling = { @@ -91,6 +91,8 @@ module "fleet" { # 8mb up from 262144 (256k) default sort_buffer_size = 8388608 } + # Uncomment to specify the RDS engine version + # engine_version = "8.0.mysql_aurora.3.07.1" } redis_config = { # See https://fleetdm.com/docs/deploy/reference-architectures#aws for instance types. diff --git a/terraform/variables.tf b/terraform/variables.tf index 25f7aa77e2..5933307f11 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -62,7 +62,7 @@ variable "certificate_arn" { variable "rds_config" { type = object({ name = optional(string, "fleet") - engine_version = optional(string, "8.0.mysql_aurora.3.04.2") + engine_version = optional(string, "8.0.mysql_aurora.3.07.1") instance_class = optional(string, "db.t4g.large") subnets = optional(list(string), []) allowed_security_groups = optional(list(string), []) @@ -80,7 +80,7 @@ variable "rds_config" { }) default = { name = "fleet" - engine_version = "8.0.mysql_aurora.3.04.2" + engine_version = "8.0.mysql_aurora.3.07.1" instance_class = "db.t4g.large" subnets = [] allowed_security_groups = [] @@ -218,7 +218,7 @@ variable "fleet_config" { mem = optional(number, 4096) cpu = optional(number, 512) pid_mode = optional(string, null) - image = optional(string, "fleetdm/fleet:v4.54.1") + image = optional(string, "fleetdm/fleet:v4.56.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) @@ -228,7 +228,6 @@ variable "fleet_config" { extra_iam_policies = optional(list(string), []) extra_execution_iam_policies = optional(list(string), []) extra_secrets = optional(map(string), {}) - security_groups = optional(list(string), null) security_group_name = optional(string, "fleet") iam_role_arn = optional(string, null) repository_credentials = optional(string, "") @@ -347,7 +346,7 @@ variable "fleet_config" { mem = 512 cpu = 256 pid_mode = null - image = "fleetdm/fleet:v4.54.1" + image = "fleetdm/fleet:v4.56.0" family = "fleet" sidecars = [] depends_on = [] diff --git a/tools/bomutils-docker/Dockerfile b/tools/bomutils-docker/Dockerfile index 2027428bec..3bcfc3bc58 100644 --- a/tools/bomutils-docker/Dockerfile +++ b/tools/bomutils-docker/Dockerfile @@ -1,27 +1,28 @@ -FROM debian:stable-slim@sha256:0f116858482fd8222b4f7e9b4cdc9a054051e67fbb8a57bc22651f0d56b45ad8 AS builder +FROM debian:stable-slim@sha256:e5365b94db65754594422a8a101c873728711c6a4df029677f4a7f7200d6e1c3 AS builder RUN apt-get update -RUN apt-get install -y build-essential autoconf libxml2-dev libssl-dev zlib1g-dev curl +RUN apt-get install -y build-essential autoconf libxml2-dev libssl-dev zlib1g-dev curl git -# Install bomutils -RUN curl -L https://github.com/hogliux/bomutils/archive/0.2.tar.gz > bomutils.tar.gz && \ - echo "fb1f4ae37045eaa034ddd921ef6e16fb961e95f0364e5d76c9867bc8b92eb8a4 bomutils.tar.gz" | sha256sum --check && \ - tar -xzf bomutils.tar.gz -RUN cd bomutils-0.2 && make && make install +# Build bomutils +RUN git clone -b master \ + --depth=1 --no-tags --progress \ + --no-recurse-submodules https://github.com/hogliux/bomutils.git && \ + cd bomutils && git reset --hard c41ad8b67d82a0071245ce8a5069023d39a885b8 && \ + make && make install # Install xar RUN curl -L https://github.com/mackyle/xar/archive/refs/tags/xar-1.6.1.tar.gz > xar.tar.gz && \ echo "5e7d50dab73f5cb1713b49fa67c455c2a0dd2b0a7770cbc81b675e21f6210e25 xar.tar.gz" | sha256sum --check && \ tar -xzf xar.tar.gz + # Note this needs patching due to newer version of OpenSSL # See https://github.com/mackyle/xar/pull/23 COPY patch.txt . RUN cd xar-xar-1.6.1/xar && patch < ../../patch.txt && autoconf && ./configure && make && make install +FROM debian:stable-slim@sha256:e5365b94db65754594422a8a101c873728711c6a4df029677f4a7f7200d6e1c3 -FROM debian:stable-slim@sha256:0f116858482fd8222b4f7e9b4cdc9a054051e67fbb8a57bc22651f0d56b45ad8 - -RUN apt-get update && apt-get install -y --no-install-recommends libxml2 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && dpkg --add-architecture i386 && apt-get upgrade -y && apt-get install -y --no-install-recommends libxml2 ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /usr/bin /usr/bin/ COPY --from=builder /usr/local/bin /usr/local/bin/ COPY --from=builder /usr/local/lib /usr/local/lib/ diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index fb717b35d2..86388d153e 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -96,7 +96,15 @@ github.com/fleetdm/fleet/v4/server/fleet/Integrations GoogleCalendar []*fleet.Go github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration Domain string github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration ApiKey map[string]string github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM -github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMDefaultTeam string +github.com/fleetdm/fleet/v4/server/fleet/MDM DeprecatedAppleBMDefaultTeam string +github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBusinessManager optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Value []fleet.MDMAppleABMAssignmentInfo +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleABMAssignmentInfo OrganizationName string +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleABMAssignmentInfo MacOSTeam string +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleABMAssignmentInfo IOSTeam string +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleABMAssignmentInfo IpadOSTeam string github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMEnabledAndConfigured bool github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMTermsExpired bool github.com/fleetdm/fleet/v4/server/fleet/MDM EnabledAndConfigured bool @@ -142,6 +150,12 @@ github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson. github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Value []fleet.MDMProfileSpec +github.com/fleetdm/fleet/v4/server/fleet/MDM VolumePurchasingProgram optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleVolumePurchasingProgramInfo] +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleVolumePurchasingProgramInfo] Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleVolumePurchasingProgramInfo] Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleVolumePurchasingProgramInfo] Value []fleet.MDMAppleVolumePurchasingProgramInfo +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleVolumePurchasingProgramInfo Location string +github.com/fleetdm/fleet/v4/server/fleet/MDMAppleVolumePurchasingProgramInfo Teams []string github.com/fleetdm/fleet/v4/server/fleet/AppConfig Scripts optjson.Slice[string] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool diff --git a/tools/dbutils/schema_generator.go b/tools/dbutils/schema_generator.go index 2fecce8683..60c01869d3 100644 --- a/tools/dbutils/schema_generator.go +++ b/tools/dbutils/schema_generator.go @@ -67,7 +67,7 @@ func main() { // Dump schema to dumpfile cmd := exec.Command( - "docker-compose", "exec", "-T", "mysql_test", + "docker", "compose", "exec", "-T", "mysql_test", // Command run inside container "mysqldump", "-u"+testUsername, "-p"+testPassword, "schemadb", "--compact", "--skip-comments", ) diff --git a/tools/fleetctl-docker/Dockerfile b/tools/fleetctl-docker/Dockerfile index 7e2e27655b..6b82ed6285 100644 --- a/tools/fleetctl-docker/Dockerfile +++ b/tools/fleetctl-docker/Dockerfile @@ -6,7 +6,7 @@ RUN cargo install --version 0.16.0 apple-codesign \ && curl -sSf $transporter_url -o transporter_install.sh \ && sh transporter_install.sh --target transporter --accept --noexec -FROM debian:stable-slim@sha256:0f116858482fd8222b4f7e9b4cdc9a054051e67fbb8a57bc22651f0d56b45ad8 +FROM debian:stable-slim@sha256:e5365b94db65754594422a8a101c873728711c6a4df029677f4a7f7200d6e1c3 ARG binpath=build/binary-bundle/linux/fleetctl diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index 63a49c5208..0db37e98d5 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.54.1", + "version": "v4.56.0", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" diff --git a/tools/mdm/apple/applebmapi/main.go b/tools/mdm/apple/applebmapi/main.go index 04cbc198d8..fcc4c8d831 100644 --- a/tools/mdm/apple/applebmapi/main.go +++ b/tools/mdm/apple/applebmapi/main.go @@ -18,7 +18,6 @@ import ( "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" - apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" kitlog "github.com/go-kit/log" ) @@ -28,12 +27,16 @@ func main() { serverPrivateKey := flag.String("server-private-key", "", "fleet server's private key (to decrypt MDM assets)") profileUUID := flag.String("profile-uuid", "", "the Apple profile UUID to retrieve") serialNum := flag.String("serial-number", "", "serial number of a device to get the device details") + orgName := flag.String("org-name", "", "organization name of the token") flag.Parse() if *serverPrivateKey == "" { log.Fatal("must provide -server-private-key") } + if *orgName == "" { + log.Fatal("must provide -org-name") + } if *profileUUID != "" && *serialNum != "" { log.Fatal("only one of -profile-uuid or -serial-number must be provided") } @@ -79,11 +82,11 @@ func main() { var res any switch { case *profileUUID != "": - res, err = depClient.GetProfile(ctx, apple_mdm.DEPName, *profileUUID) + res, err = depClient.GetProfile(ctx, *orgName, *profileUUID) case *serialNum != "": - res, err = depClient.GetDeviceDetails(ctx, apple_mdm.DEPName, *serialNum) + res, err = depClient.GetDeviceDetails(ctx, *orgName, *serialNum) default: - res, err = depClient.AccountDetail(ctx, apple_mdm.DEPName) + res, err = depClient.AccountDetail(ctx, *orgName) } if err != nil { log.Fatal(err) diff --git a/tools/mdm/assets/main.go b/tools/mdm/assets/main.go index 92021567a5..d91a246f30 100644 --- a/tools/mdm/assets/main.go +++ b/tools/mdm/assets/main.go @@ -38,15 +38,15 @@ var ( flagExportName string validNames = map[fleet.MDMAssetName]struct{}{ - fleet.MDMAssetABMCert: {}, - fleet.MDMAssetABMToken: {}, - fleet.MDMAssetABMKey: {}, - fleet.MDMAssetAPNSCert: {}, - fleet.MDMAssetAPNSKey: {}, - fleet.MDMAssetCACert: {}, - fleet.MDMAssetCAKey: {}, - fleet.MDMAssetSCEPChallenge: {}, - fleet.MDMAssetVPPToken: {}, + fleet.MDMAssetABMCert: {}, + fleet.MDMAssetABMTokenDeprecated: {}, + fleet.MDMAssetABMKey: {}, + fleet.MDMAssetAPNSCert: {}, + fleet.MDMAssetAPNSKey: {}, + fleet.MDMAssetCACert: {}, + fleet.MDMAssetCAKey: {}, + fleet.MDMAssetSCEPChallenge: {}, + fleet.MDMAssetVPPTokenDeprecated: {}, } ) @@ -114,6 +114,12 @@ func main() { log.Fatal("parsing import flags", err) } + if len(flagKey) > 32 { + // We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some + // infra setups generate keys that are longer than 32 bytes. + flagKey = flagKey[:32] + } + ds := setupDS(flagKey, flagDBUser, flagDBPass, flagDBAddress, flagDBName) defer ds.Close() @@ -146,14 +152,20 @@ func main() { log.Fatal("parsing export flags", err) } - ds := setupDS(flagKey, flagDBUser, flagDBPass, flagDBAddress, flagDBName) - defer ds.Close() - // Check required flags if flagKey == "" { log.Fatal("-key flag is required") } + if len(flagKey) > 32 { + // We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some + // infra setups generate keys that are longer than 32 bytes. + flagKey = flagKey[:32] + } + + ds := setupDS(flagKey, flagDBUser, flagDBPass, flagDBAddress, flagDBName) + defer ds.Close() + if flagDir != "" { if err := os.MkdirAll(flagDir, os.ModePerm); err != nil { log.Fatal("ensuring directory: ", err) @@ -167,9 +179,9 @@ func main() { fleet.MDMAssetAPNSCert, fleet.MDMAssetABMCert, fleet.MDMAssetABMKey, - fleet.MDMAssetABMToken, + fleet.MDMAssetABMTokenDeprecated, fleet.MDMAssetSCEPChallenge, - fleet.MDMAssetVPPToken, + fleet.MDMAssetVPPTokenDeprecated, } if flagExportName != "" { @@ -199,6 +211,22 @@ func main() { log.Printf("wrote %s in %s", asset.Name, path) } + + flagDir, err = filepath.Abs(flagDir) + if err != nil { + log.Fatalf("abs path: %s", err) + } + + fmt.Printf(`You can set the following on your Fleet configuration: +export FLEET_MDM_APPLE_APNS_CERT=%[1]s/apns_cert.crt +export FLEET_MDM_APPLE_APNS_KEY=%[1]s/apns_key.key +export FLEET_MDM_APPLE_SCEP_CERT=%[1]s/ca_cert.crt +export FLEET_MDM_APPLE_SCEP_KEY=%[1]s/ca_key.key +export FLEET_MDM_APPLE_SCEP_CHALLENGE=$(cat %[1]s/scep_challenge) +export FLEET_MDM_APPLE_BM_SERVER_TOKEN=%[1]s/abm_token +export FLEET_MDM_APPLE_BM_CERT=%[1]s/abm_cert.crt +export FLEET_MDM_APPLE_BM_KEY=%[1]s/abm_key.key +`, flagDir) default: log.Fatalf("invalid subcommand %s, valid subcommands: import, export", os.Args[1]) } diff --git a/tools/mdm/decrypt-disk-encryption-key/main.go b/tools/mdm/decrypt-disk-encryption-key/main.go new file mode 100644 index 0000000000..22bf6ef714 --- /dev/null +++ b/tools/mdm/decrypt-disk-encryption-key/main.go @@ -0,0 +1,56 @@ +// Command decrypt-disk-encryption-key decrypts a base64-encoded encrypted key +// using the provided X509 certificate and private key. This is typically used +// to manually decrypt a disk encryption key, e.g. BitLocker on Windows or +// FileVault on macOS. The certificate and private key used are the SCEP files +// for a macOS host and the WSTEP files for a Windows host. +// +// Example usage (running from the root of this repository): +// +// go run ./tools/mdm/decrypt-disk-encryption-key/main.go -cert path/to/file.crt \ +// -key path/to/file.key -value-to-decrypt base64-encoded-value +package main + +import ( + "errors" + "flag" + "fmt" + + "github.com/apex/log" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/mdm" +) + +func main() { + var ( + certFile = flag.String("cert", "", "The path to the X509 certificate file (required).") + keyFile = flag.String("key", "", "The path to the X509 private key file (required).") + valueToDecrypt = flag.String("value-to-decrypt", "", "The base64-encoded value to decrypt (required).") + ) + flag.Parse() + + if *certFile == "" || *keyFile == "" || *valueToDecrypt == "" { + flag.Usage() + return + } + + cfg := config.MDMConfig{ + WindowsWSTEPIdentityCert: *certFile, + WindowsWSTEPIdentityKey: *keyFile, + } + cert, _, _, err := cfg.MicrosoftWSTEP() + if err != nil { + // unwrap the error once to remove "Microsoft WSTEP" from the error + // message, as we don't know in this tool if the cert is for WSTEP or SCEP + // (it doesn't matter) + if uerr := errors.Unwrap(err); uerr != nil { + err = uerr + } + log.Fatalf("Error loading certificate: %v", err) + } + + decrypted, err := mdm.DecryptBase64CMS(*valueToDecrypt, cert.Leaf, cert.PrivateKey) + if err != nil { + log.Fatalf("Error decrypting value: %v", err) + } + fmt.Printf("Decrypted value: %s\n", string(decrypted)) +} diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile index 5d3369304b..6355224466 100644 --- a/tools/mdm/migration/mdmproxy/Dockerfile +++ b/tools/mdm/migration/mdmproxy/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.5-alpine3.20@sha256:8c9183f715b0b4eca05b8b3dbf59766aaedb41ec07477b132ee2891ac0110a07 +FROM golang:1.23.1-alpine3.20@sha256:436e2d978524b15498b98faa367553ba6c3655671226f500c72ceb7afb2ef0b1 ARG TAG RUN apk update && apk add --no-cache git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build . diff --git a/tools/mdm/migration/mdmproxy/README.md b/tools/mdm/migration/mdmproxy/README.md index 3582bf4af4..a1f43be328 100644 --- a/tools/mdm/migration/mdmproxy/README.md +++ b/tools/mdm/migration/mdmproxy/README.md @@ -25,4 +25,18 @@ Usage of ./mdmproxy: ### Example invocation ``` mdmproxy --migrate-udids '' --auth-token foo --existing-url https://3.14.233.249 --existing-hostname micromdm.example.com --fleet-url https://example.cloud.fleetdm.com --migrate-percentage 0 -``` \ No newline at end of file +``` + +### Check migration status + +To check the migration status for a given UDID, provide the `--migrate-udids` and +`--migrate-percentage` flags with the `--check` flag: + +``` +$ go run . --migrate-percentage=50 --check E5C6DBBA-D5CC-4DB6-9560-995F17FB7A59 +E5C6DBBA-D5CC-4DB6-9560-995F17FB7A59 IS NOT migrated +$ go run . --migrate-percentage=50 --check 575424CB-09D7-4CAD-8A7A-D3511FE8A7E2 +575424CB-09D7-4CAD-8A7A-D3511FE8A7E2 IS migrated +``` + +When the `--check` flag is used, the program prints the migration status and exits. The server is not started. \ No newline at end of file diff --git a/tools/mdm/migration/mdmproxy/mdmproxy.go b/tools/mdm/migration/mdmproxy/mdmproxy.go index b6f3f18e14..c59bf4b425 100644 --- a/tools/mdm/migration/mdmproxy/mdmproxy.go +++ b/tools/mdm/migration/mdmproxy/mdmproxy.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" "strconv" "strings" "sync" @@ -83,14 +84,6 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) { return } - if !strings.HasPrefix(r.URL.Path, "/mdm") { - if m.logSkipped { - log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String()) - } - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - // Send all micromdm repo requests to the existing server if strings.HasPrefix(r.URL.Path, "/repo") { log.Printf("%s %s -> Existing (Repo)", r.Method, r.URL.String()) @@ -99,6 +92,14 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) { } + if !strings.HasPrefix(r.URL.Path, "/mdm") { + if m.logSkipped { + log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String()) + } + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // Read the body of the request body, err := io.ReadAll(r.Body) _ = r.Body.Close() @@ -321,35 +322,13 @@ func main() { serverAddr := flag.String("server-address", ":8080", "Address for server to listen on") debug := flag.Bool("debug", false, "Enable debug logging") logSkipped := flag.Bool("log-skipped", false, "Log skipped requests (usually from web scanners)") + check := flag.String("check", "", "Print whether the specified UDID is migrated with the current configuration, then exit") flag.Parse() - // Check required flags - if *existingURL == "" { - log.Fatal("--existing-url must be set") - } - if *existingHostname == "" { - log.Fatal("--existing-hostname must be set") - } - if *fleetURL == "" { - log.Fatal("--fleet-url must be set") - } - udids, err := processUDIDs(bytes.NewBufferString(*migrateUDIDs)) if err != nil { panic(err) } - log.Printf("--migrate-udids set: %v", udids) - log.Printf("--migrate-percentage set: %d", *migratePercentage) - log.Printf("--existing-url set: %s", *existingURL) - log.Printf("--existing-hostname set: %s", *existingHostname) - log.Printf("--fleet-url set: %s", *fleetURL) - log.Printf("--debug set: %v", *debug) - log.Printf("--log-skipped set: %v", *logSkipped) - if *authToken != "" { - log.Printf("--auth-token set. Remote configuration enabled.") - } else { - log.Printf("--auth-token is empty. Remote configuration disabled.") - } proxy := mdmProxy{ token: *authToken, @@ -364,6 +343,39 @@ func main() { logSkipped: *logSkipped, } + if len(*check) > 0 { + if proxy.isUDIDMigrated(*check) { + fmt.Printf("%s IS migrated\n", *check) + } else { + fmt.Printf("%s IS NOT migrated\n", *check) + } + os.Exit(0) + } + + // Check required flags + if *existingURL == "" { + log.Fatal("--existing-url must be set") + } + if *existingHostname == "" { + log.Fatal("--existing-hostname must be set") + } + if *fleetURL == "" { + log.Fatal("--fleet-url must be set") + } + + log.Printf("--migrate-udids set: %v", udids) + log.Printf("--migrate-percentage set: %d", *migratePercentage) + log.Printf("--existing-url set: %s", *existingURL) + log.Printf("--existing-hostname set: %s", *existingHostname) + log.Printf("--fleet-url set: %s", *fleetURL) + log.Printf("--debug set: %v", *debug) + log.Printf("--log-skipped set: %v", *logSkipped) + if *authToken != "" { + log.Printf("--auth-token set. Remote configuration enabled.") + } else { + log.Printf("--auth-token is empty. Remote configuration disabled.") + } + mux := http.NewServeMux() // Health check endpoint used for load balancers mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { diff --git a/tools/mdm/migration/micromdm/README.md b/tools/mdm/migration/micromdm/README.md new file mode 100644 index 0000000000..da6d073b8b --- /dev/null +++ b/tools/mdm/migration/micromdm/README.md @@ -0,0 +1,19 @@ +# MicroMDM webhook + +A tiny server you can use as a webhook callback for the MDM migration [end user workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow). + +It will try to unenroll the device based on the device UUID/UDID by sending a `RemoveProfile` +command. + +## Usage + +1. Find the MicroMDM API token. For the Fly.io hosted MicroMDM server it should be in + 1Password. If you're having trouble finding it, drop a message in `#g-mdm` on Slack! +2. Get the MicroMDM server URL. +3. Start the server with: + +``` +go run tools/mdm/migration/micromdm/main.go --api-token=$MICRO_MDM_TOKEN --url=https://micromdm.example.com +``` + +4. Configure Fleet to send a webhook to this server. \ No newline at end of file diff --git a/tools/mdm/migration/micromdm/main.go b/tools/mdm/migration/micromdm/main.go new file mode 100644 index 0000000000..87e73246b9 --- /dev/null +++ b/tools/mdm/migration/micromdm/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" +) + +var ( + apiToken = flag.String("api-token", "", "API token for the MicroMDM instance") + url = flag.String("url", "", "URL of the MicroMDM instance") + port = flag.String("port", "4648", "Port used by the webserver") +) + +func main() { + flag.Parse() + + if *apiToken == "" || *url == "" { + log.Fatal("--api-token and --url are required.") + } + + client := newMicroMDMClient(*apiToken, *url) + + http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + body, err := io.ReadAll(request.Body) + if err != nil { + slog.With("error", err).Error("reading request body") + writer.WriteHeader(http.StatusInternalServerError) + return + } + + if len(body) == 0 { + slog.Error("empty request body") + writer.WriteHeader(http.StatusBadRequest) + return + } + + slog.With("raw_body", string(body)).Debug("got request") + + var deviceInfo struct { + Host struct { + UUID string `json:"uuid"` + } `json:"host"` + } + if err := json.Unmarshal(body, &deviceInfo); err != nil { + slog.With("device_uuid", deviceInfo.Host.UUID, "error", err).Error("failed to unmarshal request body") + writer.WriteHeader(http.StatusBadRequest) + return + } + + slog.With("device_uuid", deviceInfo.Host.UUID).Info("attempting to unenroll from MicroMDM") + if err := client.unmanageDevice(deviceInfo.Host.UUID); err != nil { + slog.With("device_uuid", deviceInfo.Host.UUID, "error", err).Error("failed to unenroll device") + writer.WriteHeader(http.StatusBadRequest) + return + } + + slog.With("device_uuid", deviceInfo.Host.UUID).Info("device unenrolled") + }) + + slog.With("address", fmt.Sprintf("http://localhost:%s", *port)).Info("server running") + server := &http.Server{ + Addr: fmt.Sprintf(":%s", *port), + ReadHeaderTimeout: 3 * time.Second, + } + if err := server.ListenAndServe(); err != nil { + log.Fatalf(err.Error()) + } +} + +type microMDMClient struct { + url string + token string +} + +func newMicroMDMClient(apiToken, url string) *microMDMClient { + client := µMDMClient{url: url, token: apiToken} + return client +} + +func (m *microMDMClient) doWithRequest(req *http.Request) ([]byte, error) { + client := fleethttp.NewClient() + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + return body, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + return body, nil +} + +func (m *microMDMClient) do(method, path string, data any) ([]byte, error) { + var body []byte + if data != nil { + b, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshaling request body: %w", err) + } + body = b + } + + makeReq := func() (*http.Request, error) { + if len(body) > 0 { + return http.NewRequest(method, path, bytes.NewBuffer(body)) + } + + return http.NewRequest(method, path, nil) + } + + req, err := makeReq() + if err != nil { + return nil, err + } + req.Header.Add("accept", "application/json") + req.SetBasicAuth("micromdm", m.token) + return m.doWithRequest(req) +} + +func (m *microMDMClient) unmanageDevice(UUID string) error { + req := struct { + RequestType string `json:"request_type"` + UDID string `json:"udid"` + Identifier string `json:"identifier"` + }{ + RequestType: "RemoveProfile", + UDID: UUID, + Identifier: "com.github.micromdm.micromdm.enroll", + } + _, err := m.do("POST", fmt.Sprintf("%s/v1/commands", m.url), &req) + return err +} diff --git a/tools/mdm/windows/bitlocker/go.mod b/tools/mdm/windows/bitlocker/go.mod index 56c33b3236..56ee33f9d0 100755 --- a/tools/mdm/windows/bitlocker/go.mod +++ b/tools/mdm/windows/bitlocker/go.mod @@ -1,6 +1,6 @@ module bitlocker -go 1.22.4 +go 1.23.1 require github.com/go-ole/go-ole v1.3.0 diff --git a/tools/nvd/nvdvuln/nvdvuln.go b/tools/nvd/nvdvuln/nvdvuln.go index 5ba23209b7..41335c6103 100644 --- a/tools/nvd/nvdvuln/nvdvuln.go +++ b/tools/nvd/nvdvuln/nvdvuln.go @@ -69,6 +69,12 @@ func main() { } } + // All macOS apps are expected to have a bundle identifier, which influences CPE generation. + if softwareSource != nil && *softwareSource == "apps" && softwareBundleIdentifier != nil && *softwareBundleIdentifier == "" { + printf("Must set --software_bundle_identifier for macOS apps when specifying -software_source apps\n") + return + } + if err := os.MkdirAll(*dbDir, os.ModePerm); err != nil { panic(err) } diff --git a/tools/release/README.md b/tools/release/README.md index b2b1e0dbb7..4812e9ff71 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -1,4 +1,3 @@ - # Releasing Fleet ## Setup @@ -28,7 +27,7 @@ For example no tickets still in Ready / In Progress should be in the milestone w ## Minor Release (typically end of sprint) -example +Example: ``` # Build release candidate and changelogs and QA ticket ./tools/release/publish_release.sh -m diff --git a/tools/release/publish_release.sh b/tools/release/publish_release.sh index 6aec1605e7..6dab83d978 100755 --- a/tools/release/publish_release.sh +++ b/tools/release/publish_release.sh @@ -626,6 +626,15 @@ fi start_ver_tag=fleet-$start_version +# Check if there are updates to fleetctl dependencies (only when doing security updates to base images). +if [[ $(git diff $start_ver_tag ./tools/wix-docker ./tools/bomutils-docker) ]]; then + echo "⚠️ Changes in fleetctl dependencies detected, please run the following before continuing the release:" + echo "1. git tag fleetctl-docker-deps-$next_ver && git push origin fleetctl-docker-deps-$next_ver" + echo "2. Wait for the triggered https://github.com/fleetdm/fleet/actions/workflows/release-fleetctl-docker-deps.yaml build to finish." + echo "3. Smoke test the pushed images by manually running the following action: https://github.com/fleetdm/fleet/actions/workflows/test-packaging.yml" + exit 1 +fi + if [[ "$minor" == "true" ]]; then echo "Minor release from $start_version to $next_ver" # For scheduled minor releases, we want to branch off of main diff --git a/tools/terraform/go.mod b/tools/terraform/go.mod index 27d6ebca10..98d450eacc 100644 --- a/tools/terraform/go.mod +++ b/tools/terraform/go.mod @@ -1,6 +1,6 @@ module terraform-provider-fleetdm -go 1.22.4 +go 1.23.1 require ( github.com/hashicorp/terraform-plugin-framework v1.7.0 diff --git a/tools/tuf/releaser.sh b/tools/tuf/releaser.sh index cf669577c2..a3eba3d273 100755 --- a/tools/tuf/releaser.sh +++ b/tools/tuf/releaser.sh @@ -321,8 +321,9 @@ print_reminder () { elif [[ $COMPONENT == "osqueryd" ]]; then prompt "Make sure to install fleetd with '--osqueryd-channel=stable' on a Linux, Windows and macOS VM. (To smoke test the release.)" fi - else + elif [[ $ACTION != "update-timestamp" ]]; then echo "Unsupported action: $ACTION" + exit 1 fi } diff --git a/tools/tuf/status/tuf-status.go b/tools/tuf/status/tuf-status.go index 6f8aa6fd36..e62dc14f12 100644 --- a/tools/tuf/status/tuf-status.go +++ b/tools/tuf/status/tuf-status.go @@ -186,6 +186,9 @@ func channelVersionCommand() *cli.Command { "swiftDialog": { "macos": "swiftDialog.app.tar.gz", }, + "escrowBuddy": { + "macos": "escrowBuddy.pkg", + }, } var ( channel string @@ -208,7 +211,7 @@ func channelVersionCommand() *cli.Command { &cli.StringSliceFlag{ Name: "components", EnvVars: []string{"TUF_STATUS_COMPONENTS"}, - Value: cli.NewStringSlice("orbit", "desktop", "osqueryd", "nudge", "swiftDialog"), + Value: cli.NewStringSlice("orbit", "desktop", "osqueryd", "nudge", "swiftDialog", "escrowBuddy"), Destination: &components, Usage: "List of components", }, @@ -324,7 +327,7 @@ func channelVersionCommand() *cli.Command { Right: true, }) var rows [][]string - componentsInOrder := []string{"orbit", "desktop", "osqueryd", "nudge", "swiftDialog"} + componentsInOrder := []string{"orbit", "desktop", "osqueryd", "nudge", "swiftDialog", "escrowBuddy"} setIfEmpty := func(m map[string]string, k string) string { v := m[k] if v == "" { diff --git a/tools/wix-docker/Dockerfile b/tools/wix-docker/Dockerfile index cc6889eddc..f9b78915af 100644 --- a/tools/wix-docker/Dockerfile +++ b/tools/wix-docker/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:bullseye-slim@sha256:cc4cc29b4ba8182fca324920f64ff68a3b24acefd4c7ba8a2e5bd4e81ac3bacf +FROM debian:stable-slim@sha256:90128f59a7c6f6fdcb6493f587ea352d5c7507f52a6ddfba66fc56cd3d99dc2b RUN true \ && dpkg --add-architecture i386 \ diff --git a/webpack.config.js b/webpack.config.js index 8b06782811..72e49e6799 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -131,6 +131,7 @@ const config = { resolve: { extensions: [".tsx", ".ts", ".js", ".jsx", ".json"], modules: [path.resolve(path.join(repo, "./frontend")), "node_modules"], + fallback: { path: require.resolve("path-browserify") }, }, }; diff --git a/website/.sailsrc b/website/.sailsrc index 391fdf869c..0cd59c0714 100644 --- a/website/.sailsrc +++ b/website/.sailsrc @@ -7,5 +7,17400 @@ "_generatedWith": { "sails": "1.2.5", "sails-generate": "2.0.0" + }, + "builtStaticContent": { + "queries": [ + { + "name": "Get OpenSSL versions", + "platform": "linux", + "description": "Retrieves the OpenSSL version.", + "query": "SELECT name AS name, version AS version, 'deb_packages' AS source FROM deb_packages WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'apt_sources' AS source FROM apt_sources WHERE name LIKE 'openssl%' UNION SELECT name AS name, version AS version, 'rpm_packages' AS source FROM rpm_packages WHERE name LIKE 'openssl%';", + "purpose": "Informational", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-open-ssl-versions", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get authorized SSH keys", + "platform": "darwin, linux", + "description": "Presence of authorized SSH keys may be unusual on laptops. Could be completely normal on servers, but may be worth auditing for unusual keys and/or changes.", + "query": "SELECT username, authorized_keys. * FROM users CROSS JOIN authorized_keys USING (uid);", + "purpose": "Informational", + "remediation": "Check out the linked table (https://github.com/fleetdm/fleet/blob/32b4d53e7f1428ce43b0f9fa52838cbe7b413eed/handbook/queries/detect-hosts-with-high-severity-vulnerable-versions-of-openssl.md#table-of-vulnerable-openssl-versions) to determine if the installed version is a high severity vulnerability and view the corresponding CVE(s)", + "tags": [ + "built-in", + "ssh" + ], + "contributors": [ + { + "name": "mike-j-thomas", + "handle": "mike-j-thomas", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/mike-j-thomas" + } + ], + "kind": "query", + "slug": "get-authorized-ssh-keys", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get authorized keys for Domain Joined Accounts", + "platform": "darwin, linux", + "description": "List authorized_keys for each user on the system.", + "query": "SELECT * FROM users CROSS JOIN authorized_keys USING(uid) WHERE username IN (SELECT distinct(username) FROM last);", + "purpose": "Informational", + "tags": [ + "active directory", + "ssh" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-authorized-keys-for-domain-joined-accounts", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get crashes", + "platform": "darwin", + "description": "Retrieve application, system, and mobile app crash logs.", + "query": "SELECT uid, datetime, responsible, exception_type, identifier, version, crash_path FROM users CROSS JOIN crashes USING (uid);", + "purpose": "Informational", + "tags": [ + "troubleshooting" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-crashes", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get installed Chrome Extensions", + "platform": "darwin, linux, windows", + "description": "List installed Chrome Extensions for all users.", + "query": "SELECT * FROM users CROSS JOIN chrome_extensions USING (uid);", + "purpose": "Informational", + "tags": [ + "browser", + "built-in", + "inventory" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-installed-chrome-extensions", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get installed Linux software", + "platform": "linux", + "description": "Get all software installed on a Linux computer, including browser plugins and installed packages. Note that this does not include other running processes in the processes table.", + "query": "SELECT name AS name, version AS version, 'Package (APT)' AS type, 'apt_sources' AS source FROM apt_sources UNION SELECT name AS name, version AS version, 'Package (deb)' AS type, 'deb_packages' AS source FROM deb_packages UNION SELECT package AS name, version AS version, 'Package (Portage)' AS type, 'portage_packages' AS source FROM portage_packages UNION SELECT name AS name, version AS version, 'Package (RPM)' AS type, 'rpm_packages' AS source FROM rpm_packages UNION SELECT name AS name, '' AS version, 'Package (YUM)' AS type, 'yum_sources' AS source FROM yum_sources UNION SELECT name AS name, version AS version, 'Package (NPM)' AS type, 'npm_packages' AS source FROM npm_packages UNION SELECT name AS name, version AS version, 'Package (Python)' AS type, 'python_packages' AS source FROM python_packages;", + "purpose": "Informational", + "tags": [ + "inventory", + "built-in" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-installed-linux-software", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get installed macOS software", + "platform": "darwin", + "description": "Get all software installed on a macOS computer, including apps, browser plugins, and installed packages. Note that this does not include other running processes in the processes table.", + "query": "SELECT name AS name, bundle_short_version AS version, 'Application (macOS)' AS type, 'apps' AS source FROM apps UNION SELECT name AS name, version AS version, 'Package (Python)' AS type, 'python_packages' AS source FROM python_packages UNION SELECT name AS name, version AS version, 'Browser plugin (Chrome)' AS type, 'chrome_extensions' AS source FROM chrome_extensions UNION SELECT name AS name, version AS version, 'Browser plugin (Firefox)' AS type, 'firefox_addons' AS source FROM firefox_addons UNION SELECT name As name, version AS version, 'Browser plugin (Safari)' AS type, 'safari_extensions' AS source FROM safari_extensions UNION SELECT name AS name, version AS version, 'Package (Homebrew)' AS type, 'homebrew_packages' AS source FROM homebrew_packages;", + "purpose": "Informational", + "tags": [ + "inventory", + "built-in" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-installed-mac-os-software", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get installed Safari extensions", + "platform": "darwin", + "description": "Retrieves the list of installed Safari Extensions for all users in the target system.", + "query": "SELECT safari_extensions.* FROM users join safari_extensions USING (uid);", + "purpose": "Informational", + "tags": [ + "browser", + "built-in", + "inventory" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-installed-safari-extensions", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get installed Windows software", + "platform": "windows", + "description": "Get all software installed on a Windows computer, including programs, browser plugins, and installed packages. Note that this does not include other running processes in the processes table.", + "query": "SELECT name AS name, version AS version, 'Program (Windows)' AS type, 'programs' AS source FROM programs UNION SELECT name AS name, version AS version, 'Package (Python)' AS type, 'python_packages' AS source FROM python_packages UNION SELECT name AS name, version AS version, 'Browser plugin (IE)' AS type, 'ie_extensions' AS source FROM ie_extensions UNION SELECT name AS name, version AS version, 'Browser plugin (Chrome)' AS type, 'chrome_extensions' AS source FROM chrome_extensions UNION SELECT name AS name, version AS version, 'Browser plugin (Firefox)' AS type, 'firefox_addons' AS source FROM firefox_addons UNION SELECT name AS name, version AS version, 'Package (Chocolatey)' AS type, 'chocolatey_packages' AS source FROM chocolatey_packages;", + "purpose": "Informational", + "tags": [ + "inventory", + "built-in" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-installed-windows-software", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get laptops with failing batteries", + "platform": "darwin", + "description": "Lists all laptops with under-performing or failing batteries.", + "query": "SELECT * FROM battery WHERE health != 'Good' AND condition NOT IN ('', 'Normal');", + "purpose": "Informational", + "tags": [ + "troubleshooting", + "hardware", + "inventory" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-laptops-with-failing-batteries", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get current users with active shell/console on the system", + "platform": "darwin, linux, windows", + "description": "Get current users with active shell/console on the system and associated process", + "query": "SELECT user,host,time, p.name, p.cmdline, p.cwd, p.root FROM logged_in_users liu, processes p WHERE liu.pid = p.pid and liu.type='user' and liu.user <> '' ORDER BY time;", + "purpose": "Informational", + "tags": [ + "hunting", + "built-in" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-current-users-with-active-shell-console-on-the-system", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get unencrypted SSH keys for local accounts", + "platform": "darwin, linux, windows", + "description": "Identify SSH keys created without a passphrase which can be used in Lateral Movement (MITRE. TA0008)", + "query": "SELECT uid, username, description, path, encrypted FROM users CROSS JOIN user_ssh_keys using (uid) WHERE encrypted=0;", + "purpose": "Informational", + "tags": [ + "inventory", + "compliance", + "ssh", + "built-in" + ], + "remediation": "First, make the user aware about the impact of SSH keys. Then rotate the unencrypted keys detected.", + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-unencrypted-ssh-keys-for-local-accounts", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get unencrypted SSH keys for domain-joined accounts", + "platform": "darwin, linux, windows", + "description": "Identify SSH keys created without a passphrase which can be used in Lateral Movement (MITRE. TA0008)", + "query": "SELECT uid, username, description, path, encrypted FROM users CROSS JOIN user_ssh_keys using (uid) WHERE encrypted=0 and username in (SELECT distinct(username) FROM last);", + "purpose": "Informational", + "tags": [ + "inventory", + "compliance", + "ssh", + "active directory" + ], + "remediation": "First, make the user aware about the impact of SSH keys. Then rotate the unencrypted keys detected.", + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-unencrypted-ssh-keys-for-domain-joined-accounts", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get dynamic linker hijacking on Linux (MITRE. T1574.006)", + "platform": "linux", + "description": "Detect any processes that run with LD_PRELOAD environment variable", + "query": "SELECT env.pid, env.key, env.value, p.name,p.path, p.cmdline, p.cwd FROM process_envs env join processes p USING (pid) WHERE key='LD_PRELOAD';", + "purpose": "Informational", + "tags": [ + "hunting", + "attack", + "t1574" + ], + "remediation": "Identify the process/binary detected and confirm with the system's owner.", + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-dynamic-linker-hijacking-on-linux-mitre-t-1574-006", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get dynamic linker hijacking on macOS (MITRE. T1574.006)", + "platform": "darwin", + "description": "Detect any processes that run with DYLD_INSERT_LIBRARIES environment variable", + "query": "SELECT env.pid, env.key, env.value, p.name,p.path, p.cmdline, p.cwd FROM process_envs env join processes p USING (pid) WHERE key='DYLD_INSERT_LIBRARIES';", + "purpose": "Informational", + "tags": [ + "hunting", + "attack", + "t1574" + ], + "remediation": "Identify the process/binary detected and confirm with the system's owner.", + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-dynamic-linker-hijacking-on-mac-os-mitre-t-1574-006", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get etc hosts entries", + "platform": "darwin, linux", + "description": "Line-parsed /etc/hosts", + "query": "SELECT * FROM etc_hosts WHERE address not in ('127.0.0.1', '::1');", + "purpose": "informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-etc-hosts-entries", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get network interfaces", + "platform": "darwin, linux, windows", + "description": "Network interfaces MAC address", + "query": "SELECT a.interface, a.address, d.mac FROM interface_addresses a JOIN interface_details d USING (interface) WHERE address not in ('127.0.0.1', '::1');", + "purpose": "informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-network-interfaces", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get local user accounts", + "platform": "darwin, linux, windows", + "description": "Local user accounts (including domain accounts that have logged on locally (Windows)).", + "query": "SELECT uid, gid, username, description, directory, shell FROM users;", + "purpose": "informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-local-user-accounts", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get active user accounts on servers", + "platform": "linux", + "description": "Domain Joined environments normally have root or other service only accounts and users are SSH-ing using their Domain Accounts.", + "query": "SELECT * FROM shadow WHERE password_status='active' and username!='root';", + "purpose": "informational", + "tags": [ + "hunting", + "inventory", + "active directory" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-active-user-accounts-on-servers", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get Nmap scanner", + "platform": "darwin, linux, windows", + "description": "Get Nmap scanner process, as well as its user, parent, and process details.", + "query": "SELECT p.pid, name, p.path, cmdline, cwd, start_time, parent, (SELECT name FROM processes WHERE pid=p.parent) AS parent_name, (SELECT username FROM users WHERE uid=p.uid) AS username FROM processes as p WHERE cmdline like 'nmap%';", + "purpose": "Informational", + "tags": [ + "hunting", + "attack", + "t1046" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-nmap-scanner", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get Docker contained processes on a system", + "platform": "darwin, linux", + "description": "Docker containers Processes, can be used on normal systems or a kubenode.", + "query": "SELECT c.id, c.name, c.image, c.image_id, c.command, c.created, c.state, c.status, p.cmdline FROM docker_containers c CROSS JOIN docker_container_processes p using(id);", + "purpose": "Informational", + "tags": [ + "built-in", + "containers", + "inventory" + ], + "contributors": [ + { + "name": "anelshaer", + "handle": "anelshaer", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/anelshaer" + } + ], + "kind": "query", + "slug": "get-docker-contained-processes-on-a-system", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get Windows print spooler remote code execution vulnerability", + "platform": "windows", + "description": "Detects devices that are potentially vulnerable to CVE-2021-1675 because the print spooler service is not disabled.", + "query": "SELECT CASE cnt WHEN 2 THEN \"TRUE\" ELSE \"FALSE\" END \"Vulnerable\" FROM (SELECT name start_type, COUNT(name) AS cnt FROM services WHERE name = 'NTDS' or (name = 'Spooler' and start_type <> 'DISABLED')) WHERE cnt = 2;", + "purpose": "Informational", + "tags": [ + "vulnerability" + ], + "contributors": [ + { + "name": "maravedi", + "handle": "maravedi", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/maravedi" + } + ], + "kind": "query", + "slug": "get-windows-print-spooler-remote-code-execution-vulnerability", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get local users and their privileges", + "platform": "darwin, linux, windows", + "description": "Collects the local user accounts and their respective user group.", + "query": "SELECT uid, username, type, groupname FROM users u JOIN groups g ON g.gid = u.gid;", + "purpose": "informational", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "noahtalerman", + "handle": "noahtalerman", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/noahtalerman" + } + ], + "kind": "query", + "slug": "get-local-users-and-their-privileges", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get processes that no longer exist on disk", + "platform": "linux, darwin, windows", + "description": "Lists all processes of which the binary which launched them no longer exists on disk. Attackers often delete files from disk after launching a process to mask presence.", + "query": "SELECT name, path, pid FROM processes WHERE on_disk = 0;", + "purpose": "Incident response", + "tags": [ + "hunting", + "built-in" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-processes-that-no-longer-exist-on-disk", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get user files matching a specific hash", + "platform": "darwin, linux", + "description": "Looks for specific hash in the Users/ directories for files that are less than 50MB (osquery file size limitation.)", + "query": "SELECT path, sha256 FROM hash WHERE path IN (SELECT path FROM file WHERE size < 50000000 AND path LIKE '/Users/%/Documents/%%') AND sha256 = '16d28cd1d78b823c4f961a6da78d67a8975d66cde68581798778ed1f98a56d75';", + "purpose": "Informational", + "tags": [ + "hunting", + "built-in" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-user-files-matching-a-specific-hash", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get local administrator accounts on macOS", + "platform": "darwin", + "description": "The query allows you to check macOS systems for local administrator accounts.", + "query": "SELECT uid, username, type FROM users u JOIN groups g ON g.gid = u.gid;", + "purpose": "Informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-local-administrator-accounts-on-mac-os", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get all listening ports, by process", + "platform": "linux, darwin, windows", + "description": "List ports that are listening on all interfaces, along with the process to which they are attached.", + "query": "SELECT lp.address, lp.pid, lp.port, lp.protocol, p.name, p.path, p.cmdline FROM listening_ports lp JOIN processes p ON lp.pid = p.pid WHERE lp.address = \"0.0.0.0\";", + "purpose": "Informational", + "tags": [ + "hunting", + "network" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-all-listening-ports-by-process", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get whether TeamViewer is installed/running", + "platform": "windows", + "description": "Looks for the TeamViewer service running on machines. This is often used when attackers gain access to a machine, running TeamViewer to allow them to access a machine.", + "query": "SELECT display_name,status,s.pid,p.path FROM services AS s JOIN processes AS p USING(pid) WHERE s.name LIKE \"%teamviewer%\";", + "purpose": "Informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-whether-team-viewer-is-installed-running", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get malicious Python backdoors", + "platform": "darwin, linux, windows", + "description": "Watches for the backdoored Python packages installed on the system. See (http://www.nbu.gov.sk/skcsirt-sa-20170909-pypi/index.html)", + "query": "SELECT CASE cnt WHEN 0 THEN \"NONE_INSTALLED\" ELSE \"INSTALLED\" END AS \"Malicious Python Packages\", package_name, package_version FROM (SELECT COUNT(name) AS cnt, name AS package_name, version AS package_version, path AS package_path FROM python_packages WHERE package_name IN ('acquisition', 'apidev-coop', 'bzip', 'crypt', 'django-server', 'pwd', 'setup-tools', 'telnet', 'urlib3', 'urllib'));", + "purpose": "Informational", + "tags": [ + "hunting", + "inventory", + "malware" + ], + "contributors": [ + { + "name": "alphabrevity", + "handle": "alphabrevity", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/alphabrevity" + } + ], + "kind": "query", + "slug": "get-malicious-python-backdoors", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Check for artifacts of the Floxif trojan", + "platform": "windows", + "description": "Checks for artifacts from the Floxif trojan on Windows machines.", + "query": "SELECT * FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\Piriform\\\\Agomo%';", + "purpose": "Informational", + "tags": [ + "hunting", + "malware" + ], + "contributors": [ + { + "name": "micheal-o", + "handle": "micheal-o", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/micheal-o" + } + ], + "kind": "query", + "slug": "check-for-artifacts-of-the-floxif-trojan", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get Shimcache table", + "platform": "windows", + "description": "Returns forensic data showing evidence of likely file execution, in addition to the last modified timestamp of the file, order of execution, full file path order of execution, and the order in which files were executed.", + "query": "select * from Shimcache", + "purpose": "Informational", + "tags": [ + "hunting" + ], + "contributors": [ + { + "name": "puffyCid", + "handle": "puffyCid", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/puffyCid" + } + ], + "kind": "query", + "slug": "get-shimcache-table", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get running docker containers", + "platform": "darwin, linux", + "description": "Returns the running Docker containers", + "query": "SELECT id, name, image, image_id, state, status FROM docker_containers WHERE state = \"running\";", + "purpose": "Informational", + "tags": [ + "containers", + "inventory" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-running-docker-containers", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get applications hogging memory", + "platform": "darwin, linux, windows", + "description": "Returns top 10 applications or processes hogging memory the most.", + "query": "SELECT pid, name, ROUND((total_size * '10e-7'), 2) AS memory_used FROM processes ORDER BY total_size DESC LIMIT 10;", + "purpose": "Informational", + "tags": [ + "troubleshooting" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-applications-hogging-memory", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get servers with root login in the last 24 hours", + "platform": "darwin, linux, windows", + "description": "Returns servers with root login in the last 24 hours and the time the users were logged in.", + "query": "SELECT * FROM last WHERE username = \"root\" AND time > (( SELECT unix_time FROM time ) - 86400 );", + "purpose": "Informational", + "tags": [ + "hunting" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-servers-with-root-login-in-the-last-24-hours", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Detect active processes with Log4j running", + "platform": "darwin, linux", + "description": "Returns a list of active processes and the Jar paths which are using Log4j. Version numbers are usually within the Jar filename. Note: This query is resource intensive and has caused problems on systems with limited swap space. Test on some systems before running this widely.", + "query": "WITH target_jars AS (\n SELECT DISTINCT path\n FROM (\n WITH split(word, str) AS(\n SELECT '', cmdline || ' '\n FROM processes\n UNION ALL\n SELECT substr(str, 0, instr(str, ' ')), substr(str, instr(str, ' ') + 1)\n FROM split\n WHERE str != '')\n SELECT word AS path\n FROM split\n WHERE word LIKE '%.jar'\n UNION ALL\n SELECT path\n FROM process_open_files\n WHERE path LIKE '%.jar'\n )\n)\nSELECT path, matches\nFROM yara\nWHERE path IN (SELECT path FROM target_jars)\n AND count > 0\n AND sigrule IN (\n 'rule log4jJndiLookup {\n strings:\n $jndilookup = \"JndiLookup\"\n condition:\n $jndilookup\n }',\n 'rule log4jJavaClass {\n strings:\n $javaclass = \"org/apache/logging/log4j\"\n condition:\n $javaclass\n }'\n );\n", + "purpose": "Detection", + "tags": [ + "vulnerability" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + }, + { + "name": "tgauda", + "handle": "tgauda", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/tgauda" + } + ], + "kind": "query", + "slug": "detect-active-processes-with-log-4-j-running", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get applications that were opened within the last 24 hours", + "platform": "darwin", + "description": "Returns applications that were opened within the last 24 hours starting with the last opened application.", + "query": "SELECT * FROM apps WHERE last_opened_time > (( SELECT unix_time FROM time ) - 86400 ) ORDER BY last_opened_time DESC;", + "purpose": "Informational", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-applications-that-were-opened-within-the-last-24-hours", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get applications that are not in the Applications directory", + "platform": "darwin", + "description": "Returns applications that are not in the `/Applications` directory", + "query": "SELECT * FROM apps WHERE path NOT LIKE '/Applications/%';", + "purpose": "Informational", + "tags": [ + "hunting", + "inventory" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-applications-that-are-not-in-the-applications-directory", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get subscription-based applications that have not been opened for the last 30 days", + "platform": "darwin", + "description": "Returns applications that are subscription-based and have not been opened for the last 30 days. You can replace the list of applications with those specific to your use case.", + "query": "SELECT * FROM apps WHERE path LIKE '/Applications/%' AND name IN (\"Photoshop.app\", \"Adobe XD.app\", \"Sketch.app\", \"Illustrator.app\") AND last_opened_time < (( SELECT unix_time FROM time ) - 2592000000000 );", + "purpose": "Informational", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "DominusKelvin", + "handle": "DominusKelvin", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/DominusKelvin" + } + ], + "kind": "query", + "slug": "get-subscription-based-applications-that-have-not-been-opened-for-the-last-30-days", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get operating system information", + "platform": "darwin, windows, linux", + "description": "Returns the operating system name and version on the device.", + "query": "SELECT name, version FROM os_version;", + "purpose": "Informational", + "tags": [ + "inventory", + "built-in" + ], + "contributors": [ + { + "name": "noahtalerman", + "handle": "noahtalerman", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/noahtalerman" + } + ], + "kind": "query", + "slug": "get-operating-system-information", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Gatekeeper enabled (macOS)", + "query": "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;", + "description": "Checks to make sure that the Gatekeeper feature is enabled on macOS devices. Gatekeeper tries to ensure only trusted software is run on a mac machine.", + "resolution": "To enable Gatekeeper, on the failing device, run the following command in the Terminal app: /usr/sbin/spctl --master-enable.", + "tags": [ + "compliance", + "hardening", + "built-in", + "cis", + "cis2.5.2.1" + ], + "platform": "darwin", + "contributors": [ + { + "name": "groob", + "handle": "groob", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/groob" + } + ], + "kind": "policy", + "slug": "gatekeeper-enabled-mac-os", + "requiresMdm": false, + "critical": true + }, + { + "name": "Full disk encryption enabled (Windows)", + "query": "SELECT 1 FROM bitlocker_info WHERE drive_letter='C:' AND protection_status=1;", + "description": "Checks to make sure that full disk encryption is enabled on Windows devices.", + "resolution": "To get additional information, run the following osquery query on the failing device: SELECT * FROM bitlocker_info. In the query results, if protection_status is 2, then the status cannot be determined. If it is 0, it is considered unprotected. Use the additional results (percent_encrypted, conversion_status, etc.) to help narrow down the specific reason why Windows considers the volume unprotected.", + "platform": "windows", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "full-disk-encryption-enabled-windows", + "requiresMdm": false, + "critical": true + }, + { + "name": "Full disk encryption enabled (macOS)", + "query": "SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT \"\" AND filevault_status = 'on' LIMIT 1;", + "description": "Checks to make sure that full disk encryption (FileVault) is enabled on macOS devices.", + "resolution": "To enable full disk encryption, on the failing device, select System Preferences > Security & Privacy > FileVault > Turn On FileVault.", + "tags": [ + "compliance", + "hardening", + "built-in", + "cis", + "cis2.5.1.1" + ], + "platform": "darwin", + "contributors": [ + { + "name": "groob", + "handle": "groob", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/groob" + } + ], + "kind": "policy", + "slug": "full-disk-encryption-enabled-mac-os", + "requiresMdm": false, + "critical": true + }, + { + "name": "Full disk encryption enabled (Linux)", + "query": "SELECT 1 FROM disk_encryption WHERE encrypted=1 AND name LIKE '/dev/dm-1';", + "description": "Checks if the root drive is encrypted. There are many ways to encrypt Linux systems. This is the default on distributions such as Ubuntu.", + "resolution": "Ensure the image deployed to your Linux workstation includes full disk encryption.", + "platform": "linux", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "full-disk-encryption-enabled-linux", + "requiresMdm": false, + "critical": true + }, + { + "name": "System Integrity Protection enabled (macOS)", + "query": "SELECT 1 FROM sip_config WHERE config_flag = 'sip' AND enabled = 1;", + "description": "Checks to make sure that the System Integrity Protection feature is enabled.", + "resolution": "To enable System Integrity Protection, on the failing device, run the following command in the Terminal app: /usr/sbin/spctl --master-enable.", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in", + "cis", + "cis5.1.2" + ], + "platform": "darwin", + "contributors": [ + { + "name": "groob", + "handle": "groob", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/groob" + } + ], + "kind": "policy", + "slug": "system-integrity-protection-enabled-mac-os", + "requiresMdm": false + }, + { + "name": "Automatic login disabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain = 'com.apple.loginwindow' AND name = 'com.apple.login.mcx.DisableAutoLoginClient' AND value = 1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to prevent login in without a password.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that disables automatic login.", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "groob", + "handle": "groob", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/groob" + } + ], + "kind": "policy", + "slug": "automatic-login-disabled-mac-os", + "requiresMdm": true, + "critical": true + }, + { + "name": "Secure keyboard entry for Terminal application enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain = 'com.apple.Terminal' AND name = 'SecureKeyboardEntry' AND value = 1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to enabled secure keyboard entry for the Terminal application.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables secure keyboard entry for the Terminal application.", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "groob", + "handle": "groob", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/groob" + } + ], + "kind": "policy", + "slug": "secure-keyboard-entry-for-terminal-application-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Get built-in antivirus status on macOS", + "platform": "darwin", + "query": "SELECT path, value AS version FROM plist WHERE (key = 'CFBundleShortVersionString' AND path = '/Library/Apple/System/Library/CoreServices/MRT.app/Contents/Info.plist') OR (key = 'CFBundleShortVersionString' AND path = '/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Info.plist');", + "description": "Reads the version numbers from the Malware Removal Tool (MRT) and built-in antivirus (XProtect) plists", + "purpose": "Informational", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "query", + "slug": "get-built-in-antivirus-status-on-mac-os", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get antivirus status from the Windows Security Center", + "platform": "windows", + "query": "SELECT antivirus, signatures_up_to_date from windows_security_center CROSS JOIN windows_security_products WHERE type = 'Antivirus';", + "description": "Selects the antivirus and signatures status from Windows Security Center.", + "purpose": "Informational", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "query", + "slug": "get-antivirus-status-from-the-windows-security-center", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get antivirus (ClamAV/clamd) and updater (freshclam) process status", + "platform": "linux", + "query": "SELECT pid, state, cmdline, name FROM processes WHERE name='clamd' OR name='freshclam';", + "description": "Selects the clamd and freshclam processes to ensure AV and its updater are running", + "purpose": "Informational", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "query", + "slug": "get-antivirus-clam-av-clamd-and-updater-freshclam-process-status", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Antivirus healthy (macOS)", + "query": "SELECT score FROM (SELECT case when COUNT(*) = 2 then 1 ELSE 0 END AS score FROM plist WHERE (key = 'CFBundleShortVersionString' AND path = '/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Info.plist' AND value>=2162) OR (key = 'CFBundleShortVersionString' AND path = '/Library/Apple/System/Library/CoreServices/MRT.app/Contents/Info.plist' and value>=1.93)) WHERE score == 1;", + "description": "Checks the version of Malware Removal Tool (MRT) and the built-in macOS AV (Xprotect). Replace version numbers with the latest version regularly.", + "resolution": "To enable automatic security definition updates, on the failing device, select System Preferences > Software Update > Advanced > Turn on Install system data files and security updates.", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in", + "template" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "antivirus-healthy-mac-os", + "requiresMdm": false + }, + { + "name": "Antivirus healthy (Windows)", + "query": "SELECT 1 from windows_security_center wsc CROSS JOIN windows_security_products wsp WHERE antivirus = 'Good' AND type = 'Antivirus' AND signatures_up_to_date=1;", + "description": "Checks the status of antivirus and signature updates from the Windows Security Center.", + "resolution": "Ensure Windows Defender or your third-party antivirus is running, up to date, and visible in the Windows Security Center.", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in" + ], + "platform": "windows", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "antivirus-healthy-windows", + "requiresMdm": false + }, + { + "name": "Antivirus healthy (Linux)", + "query": "SELECT score FROM (SELECT case when COUNT(*) = 2 then 1 ELSE 0 END AS score FROM processes WHERE (name = 'clamd') OR (name = 'freshclam')) WHERE score == 1;", + "description": "Checks that both ClamAV's daemon and its updater service (freshclam) are running.", + "resolution": "Ensure ClamAV and Freshclam are installed and running.", + "tags": [ + "compliance", + "malware", + "hardening", + "built-in" + ], + "platform": "linux", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "antivirus-healthy-linux", + "requiresMdm": false + }, + { + "name": "MDM enrolled (macOS)", + "query": "SELECT 1 from mdm WHERE enrolled='true';", + "description": "Required: osquery deployed with Orbit, or manual installation of macadmins/osquery-extension. Checks that a mac is enrolled to MDM. Add a AND on identity_certificate_uuid to check for a specific MDM.", + "resolution": "Enroll device to MDM", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "mdm-enrolled-mac-os", + "requiresMdm": false, + "critical": true + }, + { + "name": "Docker application is up to date or not present (macOS)", + "query": "SELECT 1 WHERE EXISTS (SELECT 1 FROM apps a1 WHERE a1.bundle_identifier = 'com.electron.dockerdesktop' AND a1.bundle_short_version>='4.6.1') OR NOT EXISTS (SELECT 1 FROM apps a2 WHERE a2.bundle_identifier = 'com.electron.dockerdesktop');", + "description": "Checks if the application (Docker Desktop example) is installed and up to date, or not installed. Fails if the application is installed and on a lower version. You can copy this query and replace the bundle_identifier and bundle_version values to apply the same type of policy to other applications.", + "resolution": "Update Docker or remove it if not used.", + "tags": [ + "inventory", + "vulnerability", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "docker-application-is-up-to-date-or-not-present-mac-os", + "requiresMdm": false + }, + { + "name": "SSH keys encrypted", + "query": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM users CROSS JOIN user_ssh_keys USING (uid) WHERE encrypted='0');", + "description": "Required: osquery must have Full Disk Access. Policy passes if all keys are encrypted, including if no keys are present.", + "resolution": "Use this command to encrypt existing SSH keys by providing the path to the file: ssh-keygen -o -p -f /path/to/file", + "tags": [ + "compliance", + "ssh", + "built-in" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "platform": "darwin,linux,windows", + "kind": "policy", + "slug": "ssh-keys-encrypted", + "requiresMdm": false + }, + { + "name": "Suspicious autostart (Windows)", + "query": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM startup_items WHERE path = \"regsvr32\" AND args LIKE \"%http%\");", + "description": "Checks for an autostart that is attempting to load a dynamic link library (DLL) from the internet.", + "resolution": "Remove the suspicious startup entry.", + "tags": [ + "malware", + "hunting" + ], + "platform": "windows", + "contributors": [ + { + "name": "kswagler-rh", + "handle": "kswagler-rh", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/kswagler-rh" + } + ], + "kind": "policy", + "slug": "suspicious-autostart-windows", + "requiresMdm": false + }, + { + "name": "Firewall enabled (macOS)", + "query": "SELECT 1 FROM alf WHERE global_state >= 1;", + "description": "Checks if the firewall is enabled.", + "resolution": "In System Preferences, open Security & Privacy, navigate to the Firewall tab and click Turn On Firewall.", + "tags": [ + "hardening", + "compliance", + "built-in", + "cis", + "cis2.5.2.2" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "firewall-enabled-mac-os", + "requiresMdm": false + }, + { + "name": "Screen lock enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE name='askForPassword' AND value='1';", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to enable screen lock.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables screen lock.", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "screen-lock-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Screen lock enabled (Windows)", + "query": "SELECT 1 FROM registry WHERE path = 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\InactivityTimeoutSecs' AND CAST(data as INTEGER) <= 1800;", + "description": "Checks if the screen lock is enabled and configured to lock the system within 30 minutes or less.", + "resolution": "Contact your IT administrator to enable the Interactive Logon: Machine inactivity limit setting with a value of 1800 seconds or lower.", + "tags": [ + "compliance", + "hardening", + "built-in" + ], + "platform": "windows", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "screen-lock-enabled-windows", + "requiresMdm": false + }, + { + "name": "Password requires 10 or more characters (macOS)", + "query": "SELECT 1 FROM (SELECT cast(lengthtxt as integer(2)) minlength FROM (SELECT SUBSTRING(length, 1, 2) AS lengthtxt FROM (SELECT policy_description, policy_identifier, split(policy_content, '{', 1) AS length FROM password_policy WHERE policy_identifier LIKE '%minLength')) WHERE minlength >= 10);", + "description": "Checks that the password policy requires at least 10 characters. Requires osquery 5.4.0 or newer.", + "resolution": "Contact your IT administrator to make sure your Mac is receiving configuration profiles for password length.", + "platform": "darwin", + "tags": [ + "compliance", + "hardening", + "built-in", + "cis", + "cis5.2.2" + ], + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "password-requires-10-or-more-characters-mac-os", + "requiresMdm": false + }, + { + "name": "Operating system up to date (macOS)", + "query": "SELECT 1 FROM os_version WHERE version >= '14.1.1';", + "description": "Checks that the operating system is up to date.", + "resolution": "From the Apple menu () in the corner of your screen choose System Preferences. Then select Software Update and select Upgrade Now. You might be asked to restart or enter your password.", + "tags": [ + "compliance", + "cis", + "template", + "cis1.1" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "operating-system-up-to-date-mac-os", + "requiresMdm": false, + "critical": true + }, + { + "name": "Automatic updates enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.SoftwareUpdate' AND name='AutomaticCheckEnabled' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically check for updates.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic updates.", + "tags": [ + "compliance", + "cis", + "cis1.2" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "automatic-updates-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Automatic update downloads enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.SoftwareUpdate' AND name='AutomaticDownload' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically download updates.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic update downloads.", + "tags": [ + "compliance", + "cis", + "cis1.3" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "automatic-update-downloads-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Automatic installation of application updates is enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.SoftwareUpdate' AND name='AutomaticallyInstallAppUpdates' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically install updates to App Store applications.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic installation of application updates.", + "tags": [ + "compliance", + "cis", + "cis1.4" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "automatic-installation-of-application-updates-is-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Automatic security and data file updates is enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.SoftwareUpdate' AND name='CriticalUpdateInstall' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically download updates to built-in macOS security tools such as malware removal tools.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic security and data update installation.", + "tags": [ + "compliance", + "cis", + "cis1.5" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "automatic-security-and-data-file-updates-is-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Automatic installation of operating system updates is enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.SoftwareUpdate' AND name='AutomaticallyInstallMacOSUpdates' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically install operating system updates.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic installation of operating system updates.", + "tags": [ + "compliance", + "cis", + "cis1.6" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "automatic-installation-of-operating-system-updates-is-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Time and date are configured to be updated automatically (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.applicationaccess' AND name='forceAutomaticDateAndTime' AND value=1 LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to automatically update the time and date.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables automatic time and date configuration.", + "tags": [ + "compliance", + "cis", + "cis2.2.1" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "time-and-date-are-configured-to-be-updated-automatically-mac-os", + "requiresMdm": true + }, + { + "name": "Lock screen after inactivity of 20 minutes or less (macOS)", + "query": "SELECT 1 WHERE EXISTS (SELECT CAST(value as integer(4)) valueint from managed_policies WHERE domain = 'com.apple.screensaver' AND name = 'askForPasswordDelay' AND valueint <= 60 LIMIT 1) AND EXISTS (SELECT CAST(value as integer(4)) valueint from managed_policies WHERE domain = 'com.apple.screensaver' AND name = 'idleTime' AND valueint <= 1140 LIMIT 1) AND EXISTS (SELECT 1 from managed_policies WHERE domain='com.apple.screensaver' AND name='askForPassword' AND value=1 LIMIT 1);", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to lock the screen after 20 minutes or less.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables the screen saver after inactivity of 20 minutes or less.", + "tags": [ + "compliance", + "cis", + "cis2.3.1", + "cis5.8" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "lock-screen-after-inactivity-of-20-minutes-or-less-mac-os", + "requiresMdm": true + }, + { + "name": "Internet sharing is blocked (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.MCX' AND name='forceInternetSharingOff' AND value='1' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to prevent Internet sharing.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that prevents Internet sharing.", + "tags": [ + "compliance", + "cis", + "cis2.4.2" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "internet-sharing-is-blocked-mac-os", + "requiresMdm": true + }, + { + "name": "Content caching is disabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.applicationaccess' AND name='allowContentCaching' AND value='0' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to disable content caching.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that disables content caching.", + "tags": [ + "compliance", + "cis", + "cis2.4.10" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "content-caching-is-disabled-mac-os", + "requiresMdm": true + }, + { + "name": "Ad tracking is limited (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.AdLib' AND name='forceLimitAdTracking' AND value='1' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to limit advertisement tracking.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that disables advertisement tracking.", + "tags": [ + "compliance", + "cis", + "cis2.5.6" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "ad-tracking-is-limited-mac-os", + "requiresMdm": true + }, + { + "name": "iCloud Desktop and Document sync is disabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.icloud.managed' AND name='DisableCloudSync' AND value='1' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to prevent iCloud Desktop and Documents sync.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile to prevent iCloud Desktop and Documents sync.", + "tags": [ + "compliance", + "cis", + "cis2.6.1.4" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "i-cloud-desktop-and-document-sync-is-disabled-mac-os", + "requiresMdm": true + }, + { + "name": "Firewall logging is enabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.security.firewall' AND name='EnableLogging' AND value='1' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to log firewall activity.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that enables firewall logging.", + "tags": [ + "compliance", + "cis", + "cis3.6" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "firewall-logging-is-enabled-mac-os", + "requiresMdm": true + }, + { + "name": "Guest account disabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.loginwindow' AND name='DisableGuestAccount' AND value='1' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to prevent the use of a guest account.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that disables the guest account.", + "tags": [ + "compliance", + "cis", + "cis6.1.3" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "guest-account-disabled-mac-os", + "requiresMdm": true + }, + { + "name": "Guest access to shared folders is disabled (macOS)", + "query": "SELECT 1 FROM managed_policies WHERE domain='com.apple.AppleFileServer' AND name='guestAccess' AND value='0' LIMIT 1;", + "description": "Checks that a mobile device management (MDM) solution configures the Mac to prevent guest access to shared folders.", + "resolution": "Contact your IT administrator to ensure your Mac is receiving a profile that prevents guest access to shared folders.", + "tags": [ + "compliance", + "cis", + "cis6.1.4" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "guest-access-to-shared-folders-is-disabled-mac-os", + "requiresMdm": true + }, + { + "name": "No 1Password emergency kit stored in desktop, documents, or downloads folders (macOS)", + "query": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM file WHERE filename LIKE '%Emergency Kit%.pdf' AND (path LIKE '/Users/%/Desktop/%' OR path LIKE '/Users/%/Documents/%' OR path LIKE '/Users/%/Downloads/%' OR path LIKE '/Users/Shared/%'));", + "description": "Looks for PDF files with file names typically used by 1Password for emergency recovery kits. To protect the performance of your devices, the search is one level deep and limited to the Desktop, Documents, Downloads, and Shared folders.", + "resolution": "Delete 1Password emergency kits from your computer, and empty the trash. 1Password emergency kits should only be printed and stored in a physically secure location.", + "platform": "darwin", + "tags": [ + "compliance", + "built-in" + ], + "contributors": [ + { + "name": "nonpunctual", + "handle": "nonpunctual", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/nonpunctual" + } + ], + "kind": "policy", + "slug": "no-1-password-emergency-kit-stored-in-desktop-documents-or-downloads-folders-mac-os", + "requiresMdm": false + }, + { + "name": "Discover TLS certificates", + "platform": "linux, windows, darwin", + "description": "Retrieves metadata about TLS certificates for servers listening on the local machine. Enables mTLS adoption analysis and cert expiration notifications.", + "query": "SELECT * FROM curl_certificate WHERE hostname IN (SELECT DISTINCT 'localhost:'||port FROM listening_ports WHERE protocol=6 AND address!='127.0.0.1' AND address!='::1');", + "purpose": "Informational", + "tags": [ + "network", + "tls" + ], + "contributors": [ + { + "name": "nabilschear", + "handle": "nabilschear", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/nabilschear" + } + ], + "kind": "query", + "slug": "discover-tls-certificates", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Discover Python Packages from Running Python Interpreters", + "platform": "linux, darwin", + "description": "Attempt to discover Python environments (in cwd, path to the python binary, and process command line) from running python interpreters and collect Python packages from those environments.", + "query": "SELECT * FROM python_packages WHERE directory IN (SELECT DISTINCT directory FROM (SELECT SUBSTR(path,0,INSTR(path,'/bin/'))||'/lib' AS directory FROM processes WHERE path LIKE '%/bin/%' AND path LIKE '%python%' UNION SELECT SUBSTR(cmdline,0,INSTR(cmdline,'/bin/'))||'/lib' AS directory FROM processes WHERE cmdline LIKE '%python%' AND cmdline LIKE '%/bin/%' AND path LIKE '%python%' UNION SELECT cwd||'/lib' AS directory FROM processes WHERE path LIKE '%python%'));", + "purpose": "Informational", + "tags": [ + "compliance", + "hunting" + ], + "contributors": [ + { + "name": "nabilschear", + "handle": "nabilschear", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/nabilschear" + } + ], + "kind": "query", + "slug": "discover-python-packages-from-running-python-interpreters", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Identify the default mail, http and ftp applications", + "platforms": "macOS", + "platform": "darwin", + "description": "Lists the currently enabled applications configured to handle mailto, http and ftp schemes.", + "query": "SELECT * FROM app_schemes WHERE (scheme='mailto' OR scheme='http' OR scheme='ftp') AND enabled='1';", + "purpose": "Informational", + "tags": [ + "compliance", + "hunting" + ], + "contributors": [ + { + "name": "brunerd", + "handle": "brunerd", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/brunerd" + } + ], + "kind": "query", + "slug": "identify-the-default-mail-http-and-ftp-applications", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Firewall enabled, domain profile (Windows)", + "query": "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\WindowsFirewall\\DomainProfile\\EnableFirewall' AND CAST(data as integer) = 1;", + "description": "Checks if a Group Policy configures the computer to enable the domain profile for Windows Firewall. The domain profile applies to networks where the host system can authenticate to a domain controller. Some auditors requires that this setting is configured by a Group Policy.", + "resolution": "Contact your IT administrator to ensure your computer is receiving a Group Policy that enables the domain profile for Windows Firewall.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis9.1.1" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "firewall-enabled-domain-profile-windows", + "requiresMdm": false + }, + { + "name": "Firewall enabled, private profile (Windows)", + "query": "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\WindowsFirewall\\PrivateProfile\\EnableFirewall' AND CAST(data as integer) = 1;", + "description": "Checks if a Group Policy configures the computer to enable the private profile for Windows Firewall. The private profile applies to networks where the host system is connected to a private or home network. Some auditors requires that this setting is configured by a Group Policy.", + "resolution": "Contact your IT administrator to ensure your computer is receiving a Group Policy that enables the private profile for Windows Firewall.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis9.2.1" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "firewall-enabled-private-profile-windows", + "requiresMdm": false + }, + { + "name": "Firewall enabled, public profile (Windows)", + "query": "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\WindowsFirewall\\PublicProfile\\EnableFirewall' AND CAST(data as integer) = 1;", + "description": "Checks if a Group Policy configures the computer to enable the public profile for Windows Firewall. The public profile applies to networks where the host system is connected to public networks such as Wi-Fi hotspots at coffee shops and airports. Some auditors requires that this setting is configured by a Group Policy.", + "resolution": "Contact your IT administrator to ensure your computer is receiving a Group Policy that enables the public profile for Windows Firewall.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis9.3.1" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "firewall-enabled-public-profile-windows", + "requiresMdm": false + }, + { + "name": "SMBv1 client driver disabled (Windows)", + "query": "SELECT 1 FROM windows_optional_features WHERE name = 'SMB1Protocol-Client' AND state != 1;", + "description": "Checks that the SMBv1 client is disabled.", + "resolution": "Contact your IT administrator to discuss disabling SMBv1 on your system.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis18.3.2", + "built-in" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "sm-bv-1-client-driver-disabled-windows", + "requiresMdm": false + }, + { + "name": "SMBv1 server disabled (Windows)", + "query": "SELECT 1 FROM windows_optional_features WHERE name = 'SMB1Protocol-Server' AND state != 1", + "description": "Checks that the SMBv1 server is disabled.", + "resolution": "Contact your IT administrator to discuss disabling SMBv1 on your system.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis18.3.3", + "built-in" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "sm-bv-1-server-disabled-windows", + "requiresMdm": false + }, + { + "name": "Link-Local Multicast Name Resolution (LLMNR) disabled (Windows)", + "query": "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Policies\\Microsoft\\Windows NT\\DNSClient\\EnableMulticast' AND CAST(data as integer) = 0;", + "description": "Checks if a Group Policy configures the computer to disable LLMNR. Disabling LLMNR can prevent malicious actors from gaining access to the computer's credentials. Some auditors require that this setting is configured by a Group Policy.", + "resolution": "Contact your IT administrator to ensure your computer is receiving a Group Policy that disables LLMNR on your system.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis18.5.4.2" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "link-local-multicast-name-resolution-llmnr-disabled-windows", + "requiresMdm": false + }, + { + "name": "Automatic updates enabled (Windows)", + "query": "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\Windows\\WindowsUpdate\\AU\\NoAutoUpdate' AND CAST(data as integer) = 0;", + "description": "Checks if a Group Policy configures the computer to enable Automatic Updates. When enabled, the computer downloads and installs security and other important updates automatically. Some auditors require that this setting is configured by a Group Policy.", + "resolution": "Contact your IT administrator to ensure your computer is receiving a Group policy that enables Automatic Updates.", + "platforms": "Windows", + "tags": [ + "compliance", + "cis", + "cis18.9.108.2.1" + ], + "platform": "windows", + "contributors": [ + { + "name": "defensivedepth", + "handle": "defensivedepth", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/defensivedepth" + } + ], + "kind": "policy", + "slug": "automatic-updates-enabled-windows", + "requiresMdm": false + }, + { + "name": "Identify Apple development secrets (macOS)", + "query": "SELECT * FROM keychain_items WHERE label LIKE '%ABCDEFG%';", + "description": "Identifies certificates associated with Apple development signing and notarization. Replace ABCDEFG with your company's identifier.", + "resolution": "Ensure your official Apple builds, signing and notarization happen on a centralized system, and remove these certificates from workstations.", + "tags": [ + "compliance", + "inventory", + "built-in" + ], + "platform": "darwin", + "contributors": [ + { + "name": "GuillaumeRoss", + "handle": "GuillaumeRoss", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/GuillaumeRoss" + } + ], + "kind": "policy", + "slug": "identify-apple-development-secrets-mac-os", + "requiresMdm": false + }, + { + "name": "Geolocate via ipapi.co", + "platform": "darwin, linux, windows", + "description": "Geolocate a host using the [ipapi.co](https://ipapi.co) in an emergency. Requires the curl table. [Learn more](https://fleetdm.com/guides/locate-assets-with-osquery).", + "query": "SELECT JSON_EXTRACT(result, '$.ip') AS ip, JSON_EXTRACT(result, '$.city') AS city, JSON_EXTRACT(result, '$.region') AS region, JSON_EXTRACT(result, '$.country') AS country, JSON_EXTRACT(result, '$.latitude') AS latitude, JSON_EXTRACT(result, '$.longitude') AS longitude FROM curl WHERE url = 'http://ipapi.co/json';", + "purpose": "inventory", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "geolocate-via-ipapi-co", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get Crowdstrike Falcon network content filter status", + "platform": "darwin", + "description": "Get the status of the Crowdstrike Falcon network content filter (as in \"System Settings\" > \"Network > \"Filters\").", + "query": "/* Load up the plist */ WITH extensions_plist AS (SELECT *, rowid FROM plist WHERE path = '/Library/Preferences/com.apple.networkextension.plist') /* Find the first \"Enabled\" key after the key indicating the crowdstrike app */ SELECT value AS enabled FROM extensions_plist WHERE subkey = 'Enabled' AND rowid > (SELECT rowid FROM extensions_plist WHERE value = 'com.crowdstrike.falcon.App') LIMIT 1;", + "purpose": "Informational", + "tags": [ + "crowdstrike", + "plist", + "network", + "content filter" + ], + "contributors": [ + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-crowdstrike-falcon-network-content-filter-status", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "Get a list of Visual Studio Code extensions", + "platform": "darwin, linux, windows", + "description": "Get a list of installed VS Code extensions (requires osquery > 5.11.0).", + "query": "SELECT u.username, vs.* FROM users u CROSS JOIN vscode_extensions vs USING (uid);\n", + "purpose": "Informational", + "tags": [ + "inventory" + ], + "contributors": [ + { + "name": "lucasmrod", + "handle": "lucasmrod", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/lucasmrod" + }, + { + "name": "sharon-fdm", + "handle": "sharon-fdm", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/sharon-fdm" + }, + { + "name": "zwass", + "handle": "zwass", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/zwass" + } + ], + "kind": "query", + "slug": "get-a-list-of-visual-studio-code-extensions", + "resolution": "N/A", + "requiresMdm": false + }, + { + "name": "List osquery table names", + "platform": "darwin, linux, windows", + "description": "List all table names in the schema of the currently installed version of osquery", + "query": "SELECT DISTINCT name FROM osquery_registry;", + "purpose": "Informational", + "tags": [ + "fleet", + "osquery", + "table", + "schema" + ], + "contributors": [ + { + "name": "nonpunctual", + "handle": "nonpunctual", + "avatarUrl": "https://placekitten.com/200/200", + "htmlUrl": "https://github.com/nonpunctual" + } + ], + "kind": "query", + "slug": "list-osquery-table-names", + "resolution": "N/A", + "requiresMdm": false + } + ], + "queryLibraryYmlRepoPath": "docs/01-Using-Fleet/standard-query-library/standard-query-library.yml", + "pricingTable": [ + { + "industryName": "Managed cloud", + "description": "Have Fleet host it for you (currently only available for customers with 700+ hosts. PS. Wish we could host for you? We're working on it! Please let us know if you know of a good partner. In the meantime, join fleetdm.com/support and we're happy to help you deploy Fleet yourself.)", + "pricingTableCategories": [ + "Deployment" + ], + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Managed cloud" + }, + { + "industryName": "Self-hosted", + "friendlyName": "Host it yourself", + "description": "Deploy Fleet anywhere and host it yourself, even in air-gapped environments except where technologically impossible.", + "pricingTableCategories": [ + "Deployment" + ], + "documentationUrl": "https://fleetdm.com/docs/deploy/introduction", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "buzzwords": [ + "Self-hosted" + ], + "name": "Self-hosted" + }, + { + "industryName": "Multi-tenancy", + "description": "For managed service providers to use a single instance of Fleet for multiple customers.", + "documentationUrl": "https://github.com/fleetdm/fleet/issues/9956", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Deployment" + ], + "usualDepartment": "IT", + "buzzwords": [ + "OEM", + "Private label", + "House brand", + "Clear label", + "Multi-tenancy" + ], + "tier": "Premium", + "name": "Multi-tenancy" + }, + { + "industryName": "Deployment tools", + "description": "Pre-built Terraform modules and Helm charts to help you get up and running.", + "documentationUrl": "https://fleetdm.com/docs/deploy/introduction", + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Deployment" + ], + "name": "Deployment tools" + }, + { + "industryName": "Private update registry", + "friendlyName": "Update agents from a secret URL", + "description": "Load agent code from a secret URL that you manage.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/update-agents", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "Security", + "name": "Private update registry" + }, + { + "industryName": "Control agent versions", + "description": "Manage agents remotely by setting different versions per-baseline.", + "documentationUrl": "https://fleetdm.com/docs/configuration/agent-configuration#configure-fleetd-update-channels", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "IT", + "waysToUse": [ + { + "description": "Supply-chain Levels for Software Artifacts (SLSA) attestations for the fleetd binary artifacts and server container image to enable verification that the binaries are built and uploaded using GitHub Actions from the Fleet repository at a particular commit SHA coming soon (2024-12-31)." + }, + { + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/20219" + } + ], + "name": "Control agent versions" + }, + { + "industryName": "Command line tool (CLI)", + "friendlyName": "fleetctl", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/fleetctl-cli", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Command line tool (CLI)" + }, + { + "industryName": "GitOps", + "friendlyName": "Manage endpoints in git", + "documentationUrl": "https://github.com/fleetdm/fleet-gitops", + "description": "Fork the best practices GitHub repo and use the included GitHub Actions to quickly automate Fleet console and configuration workflow management.", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "demos": { + "description": "A top savings and investment company wanted workflows and automation so that one bad actor can't brick their fleet. This way, they have to make a pull request first.", + "quote": "I don't want one bad actor to brick my fleet. I want them to make a pull request first.", + "moreInfoUrl": "https://docs.google.com/document/d/1hAQL6P--Tt3syq1MTRONAxhQA_2Vjt3oOJJt_O4xbiE/edit?disco=AAABAVnYvns&usp_dm=true#heading=h.7en766pueek4" + }, + "name": "GitOps" + }, + { + "industryName": "Two-factor authentication", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/5478", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "waysToUse": [ + { + "description": "Enforce two-factor authentication when logging in to Fleet for added security." + } + ], + "comingSoonOn": "2024-12-31", + "name": "Two-factor authentication", + "comingSoon": true + }, + { + "industryName": "Role-based access control", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/manage-access#manage-access", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Configuration" + ], + "usualDepartment": "IT", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Role-based access control" + }, + { + "industryName": "Audit logging", + "description": "Log all activity, including queries, scripts, access, etc.", + "documentationUrl": "https://fleetdm.com/docs/rest-api/rest-api#list-activities", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "pricingTableCategories": [ + "Configuration" + ], + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "usualDepartment": "Security", + "waysToUse": [ + { + "description": "Export activity of Fleet admins to your SIEM or data lake" + } + ], + "name": "Audit logging" + }, + { + "industryName": "Scope transparency", + "description": "Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn’t acceptable use of work computers.", + "tier": "Free", + "documentationUrl": "https://fleetdm.com/transparency", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Configuration" + ], + "name": "Scope transparency" + }, + { + "industryName": "Cross-platform MDM support", + "description": "macOS, Windows, and Linux.", + "documentationUrl": "https://fleetdm.com/announcements/fleet-introduces-windows-mdm", + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Cross-platform MDM support" + }, + { + "industryName": "MDM migration", + "description": "Easily move your devices from your current MDM solution to Fleet.", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-migration-guide", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "MDM migration" + }, + { + "industryName": "Zero-touch setup", + "description": "Zero-touch setup for macOS, iOS/iPadOS, and Windows.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience", + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Ship a macOS, iOS, or iPadOS device to the end user's home and have them automatically enroll to Fleet during out-of-the-box setup." + }, + { + "description": "Ship a Windows workstation to the end user's home and have them automatically enroll to Fleet during out-of-the-box setup." + }, + { + "description": "Customize the out-of-the-box setup experience for your end users." + }, + { + "description": "Install a bootstrap package to run custom scripts during the setup experience. Store the bootstrap package outside the Fleet database coming soon (2024-09-15)", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/19037" + }, + { + "description": "Require end users to authenticate with your identity provider (IdP) and agree to an end user license agreement (EULA) before they can use their new workstation" + } + ], + "name": "Zero-touch setup" + }, + { + "industryName": "Bring your own device (BYOD) enrollment", + "description": "BYOD enrollment for macOS, iOS/iPadOS (coming soon), Windows, and Android (coming soon) devices.", + "documentationUrl": "https://fleetdm.com/guides/sysadmin-diaries-device-enrollment#byod-enrollment", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Support ACME as a protocol for MDM certificate generation. Coming soon (2024-12-31)", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/15611" + } + ], + "name": "Bring your own device (BYOD) enrollment" + }, + { + "industryName": "User account sync", + "description": "Sync user accounts via Okta, AD, or any IDP.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "waysToUse": [ + { + "description": "Automatically set admin access to Fleet based on your IDP" + } + ], + "name": "User account sync" + }, + { + "industryName": "Human-endpoint mapping", + "friendlyName": "See who logs in on every computer", + "description": "Identify who logs in to any system, including login history and current sessions. Look up any host by the email address of the person using it.", + "documentationUrl": "https://fleetdm.com/docs/rest-api/rest-api#get-hosts-google-chrome-profiles", + "screenshotSrc": null, + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "buzzwords": [ + "Device users", + "human-to-device mapping" + ], + "dri": "mikermcneil", + "demos": [ + { + "description": "Security engineers at a top gaming company wanted to get demographics off their macOS, Windows, and Linux machines about who the user is and who's logged in.", + "moreInfoUrl": "https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit" + }, + { + "description": "Data engineers at a top biotech corporation needed to know who is logged into their devices.", + "quote": "So we don't know exactly what's going on after we deploy the device, we know that they are compliant with the security because we are running these stuff, but we don't know certainly who is running, who is logging in the device?", + "moreInfoUrl": "https://docs.google.com/document/d/17MNI5ykzlFjdVmQ8SPMrT1oR_hY_vkYAJx31F7l7Pv8/edit#heading=h.7en766pueek4" + } + ], + "waysToUse": [ + { + "description": "Look up computer by ActiveDirectory account" + }, + { + "description": "Find device by Google Chrome user" + }, + { + "description": "Identify who logs in to any system, including login history and current sessions." + }, + { + "description": "Look up any host by the email address of the person using it." + }, + { + "description": "Check user login history", + "moreInfoUrl": "https://www.lepide.com/how-to/audit-who-logged-into-a-computer-and-when.html#:~:text=To%20find%20out%20the%20details,logs%20in%20%E2%80%9CWindows%20Logs%E2%80%9D." + }, + { + "description": "See currently logged in users", + "moreInfoUrl": "https://www.top-password.com/blog/see-currently-logged-in-users-in-windows/" + }, + { + "description": "Get demographics off of our machines about who the user is and who's logged in", + "moreInfoUrl": "https://docs.google.com/document/d/1qFYtMoKh3zyERLhbErJOEOo2me6Bc7KOOkjKn482Sqc/edit" + }, + { + "description": "See what servers someone is logged-in on", + "moreInfoUrl": "https://community.spiceworks.com/topic/138171-is-there-a-way-to-see-what-servers-someone-is-logged-in-on" + } + ], + "name": "Human-endpoint mapping" + }, + { + "industryName": "Device inventory", + "description": "Includes a list of all devices and all hardware and software attributes for each device.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/understanding-host-vitals", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14415", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "usualDepartment": "IT", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Implement software inventory recommendations from the SANS 20 / CIS 18.", + "moreInfoUrl": "https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4" + }, + { + "description": "View a list of all hardware attributes of a device.", + "moreInfoUrl": "https://fleetdm.com/tables/system_info" + }, + { + "description": "View a list of all software and their versions installed on all your hosts.", + "moreInfoUrl": "https://fleetdm.com/docs/get-started/anatomy#software-library" + }, + { + "description": "View a list of software rolled up by title.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14674" + }, + { + "description": "Implement hardware and infrastructure inventory recommendations from the SANS 20 / CIS 18.", + "moreInfoUrl": "https://docs.google.com/document/d/1E6EQMMqrsRc6Z3YsR6Q33OaF9eAa8zLNaz4K2YzFdyo/edit#heading=h.7en766pueek4" + } + ], + "name": "Device inventory" + }, + { + "industryName": "Search inventory", + "description": "Search devices by IP, serial, hostname, and UUID.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/learn-how-to-use-fleet#how-to-ask-questions-about-your-device", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Search inventory" + }, + { + "industryName": "Targeted device scoping", + "description": "Organize devices with Teams and Labels.", + "documentationUrl": "https://fleetdm.com/guides/managing-labels-in-fleet", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Targeted device scoping" + }, + { + "industryName": "Enforce disk encryption", + "description": "Encrypt system drives on macOS and Windows computers, manage escrowed encryption keys, and report on disk encryption status (FileVault, BitLocker).", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-disk-encryption", + "friendlyName": "Ensure hard disks are encrypted", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "Security", + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "waysToUse": [ + { + "description": "Report on disk encryption status" + }, + { + "description": "Encrypt hard disks on macOS with FileVault" + }, + { + "description": "Escrow FileVault keys on macOS" + }, + { + "description": "Encrypt hard disks on Windows with BitLocker." + } + ], + "name": "Enforce disk encryption" + }, + { + "industryName": "Enforce operating system (OS) updates", + "description": "Keep operating systems up to date for macOS, iOS/iPadOS, Windows, and Android (coming soon) devices.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-macos-updates", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Enforce macOS updates via Nudge." + }, + { + "description": "Progressively enhance from Nudge to DDM-based OS updates.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/17295" + }, + { + "description": "Automatically update Windows after the end user reaches a deadline." + } + ], + "name": "Enforce operating system (OS) updates" + }, + { + "industryName": "Enforce OS settings", + "description": "MDM support for macOS, iOS/iPadOS, Windows, and Android (coming soon) devices. Management support for Linux.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-custom-os-settings", + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "waysToUse": [ + { + "description": "Deploy configuration profiles on macOS and Windows and verify that they're installed.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/13281" + }, + { + "description": "Deploy custom declaration (DDM) profiles on macOS.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14550" + }, + { + "description": "Target profiles to specific hosts using SQL.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/17315" + }, + { + "description": "Automatically re-deploy configuration profiles when they're not installed." + }, + { + "description": "Deploy configuration profiles on iOS/iPadOS." + }, + { + "description": "See a list of the upcoming MDM commands and scripts in unified queue. Coming soon (2024-07-15)", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/15920" + }, + { + "description": "Send MDM commands to tell end users to update their OS.", + "moreInfoUrl": "https://developer.apple.com/documentation/devicemanagement/schedule_an_os_update" + }, + { + "description": "Configure agent options remotely, over the air. (Includes osquery config, and osquery startup flags.).", + "moreInfoUrl": "https://fleetdm.com/docs/configuration/agent-configuration" + } + ], + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Enforce OS settings" + }, + { + "industryName": "Declarative Device Management (DDM) support for configuration profiles", + "description": "Full support for Apple DDM configuration profiles.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-os-updates#macos", + "tier": "Free", + "jamfProHasFeature": "cloudOnly", + "jamfProtectHasFeature": "cloudOnly", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Declarative Device Management (DDM) support for configuration profiles" + }, + { + "industryName": "Device health", + "friendlyName": "Automate device health", + "description": "Automatically report system health issues using webhooks or integrations, to notify or quarantine outdated or misconfigured systems that are at higher risk of vulnerabilities or theft.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/automations#automations", + "screenshotSrc": null, + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "productCategories": [ + "Device management", + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "dri": "mikermcneil", + "demos": [ + { + "description": "A large tech company used the Fleet API to block access to corporate apps for outdated operating system versions with certain \"celebrity\" vulnerabilities.", + "quote": null, + "moreInfoUrl": "https://play.goconsensus.com/s4e490bb9" + } + ], + "buzzwords": [ + "Device trust", + "Zero trust", + "Layer 7 device trust", + "Beyondcorp", + "Device attestation", + "Conditional access" + ], + "waysToUse": [ + { + "description": "Automatically manage the behavior of endpoints that are at higher risk of vulnerabilities or data loss due to their configuration or patch level." + }, + { + "description": "Block access to corporate apps for users whose devices with unexpected settings, like disabled screen lock, passwords that are too short, unencrypted hard disks, and more" + }, + { + "description": "Quickly implement conditional access based on device health using osquery and a simple device health REST API.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14920" + }, + { + "description": "Control and restore access to applications by automatically restricting access when devices do not meet particular security requirements.", + "moreInfoUrl": "https://duo.com/docs/device-health" + }, + { + "description": "Control which laptop and desktop devices can access corporate apps and websites based on what vulnerabilities it might be exposed to based on how the device is configured, whether it's up to date, its MDM enrollment status, and anything else you can build in a SQL query of Fleet's 300 data tables representing information about enrolled host systems. Coming soon (2024-09-30).", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/16236" + }, + { + "description": "Implement multivariate device trust", + "moreInfoUrl": "https://youtu.be/5sFOdpMLXQg?feature=shared&t=1445" + }, + { + "description": "Implement your own version of Google's zero trust model (BeyondCorp)", + "moreInfoUrl": "https://cloud.google.com/beyondcorp" + }, + { + "description": "Get endpoint data into ServiceNow and make your asset management teams happy", + "moreInfoUrl": "https://www.youtube.com/watch?v=aVbU6_9JoM0" + }, + { + "description": "Monitor devices that don't meet your organization's custom security policies" + }, + { + "description": "Quickly report your posture and vulnerabilities to auditors, showing remediation status and timing." + }, + { + "description": "Keep your devices compliant with customizable baselines, or use common benchmarks like CIS." + }, + { + "description": "Discover security misconfigurations that increase attack surface." + }, + { + "description": "Detect suspcious services listening on open ports that should not be connected to the internet, such as Remote Desktop Protocol (RDP).", + "moreInfoUrl": "https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20statename%20%3D%20%E2%80%9CEnabled%E2%80%9D-,OPEN%20SOCKETS,-Lastly%2C%20an%20examination" + }, + { + "description": "Discover potentially unwanted programs that increase attack surface.", + "moreInfoUrl": "https://paraflare.com/articles/vulnerability-management-via-osquery/" + }, + { + "description": "Detect self-signed certifcates" + }, + { + "description": "Detect legacy protocols with safer versions", + "moreInfoUrl": "https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WHERE%20self_signed%20%3D%201%3B-,LEGACY%20PROTOCOLS,-This%20section%20will" + }, + { + "description": "Detect exposed secrets on the command line", + "moreInfoUrl": "https://paraflare.com/articles/vulnerability-management-via-osquery/#:~:text=WDigest%20is%20disabled.-,EXPOSED%20SECRETS,-Often%2C%20to%20create" + }, + { + "description": "Detect and surface issues with devices" + }, + { + "description": "Share device health reports" + }, + { + "description": "Align endpoints with your security policies", + "moreInfoUrl": "https://www.axonius.com/use-cases/cmdb-reconciliation" + }, + { + "description": "Maximize security control coverage" + }, + { + "description": "Uncover gaps in security policies, configurations, and hygiene", + "moreInfoUrl": "https://www.axonius.com/use-cases/coverage-gap-discovery" + }, + { + "description": "Automatically apply security policies to protect endpoints against attack." + }, + { + "description": "Surface security issues in all your deployed endpoints even data centers and factories." + }, + { + "description": "Continually validate controls and policies" + }, + { + "description": "Block access to corporate apps if your end users are failing a specific number of critical policies.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/16206" + } + ], + "name": "Device health" + }, + { + "industryName": "Application deployment", + "description": "Deploy applications and security agents on macOS, iOS/iPadOS, Linux, Windows, and Android (coming soon) devices. Additionally, install macOS and iOS/iPadOS apps from the App Store (coming soon).", + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "isExperimental": "yes", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/18867", + "waysToUse": [ + { + "description": "Easily configure and install SentinelOne, Crowdstrike, and other security tools.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14921" + }, + { + "description": "Offer licenses for Photoshop and other App Sore apps for your end users." + }, + { + "description": "iOS/iPadOS coming soon (2024-08-11).", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/14899" + } + ], + "name": "Application deployment" + }, + { + "industryName": "Self-service application installation", + "description": "Allow end users to install apps through Fleet Desktop for macOS, Linux, and Windows.", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "isExperimental": "yes", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/17587", + "waysToUse": [ + { + "description": "Build scripts for Ansible deployments", + "moreInfoUrl": "https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4" + }, + { + "description": "Deploy osquery to macOS via Jamf", + "moreInfoUrl": "https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4" + }, + { + "description": "Package osquery for Linux servers via Workspace One and Windows servers via group policies", + "moreInfoUrl": "https://www.youtube.com/watch?v=qflUfLQCnwY&list=PL6-FgoWOoK2YUR4ADGsxTSL3onb-GzCnM&index=4" + } + ], + "name": "Self-service application installation" + }, + { + "industryName": "Application management", + "description": "Manage updates and patches for apps on macOS, Windows, and Linux computers.", + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "comingSoonOn": "2024-08-25", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/18865", + "name": "Application management", + "comingSoon": true + }, + { + "industryName": "Script execution", + "friendlyName": "Safely execute custom scripts (macOS, Windows, and Linux)", + "description": "Deploy and execute custom scripts using a REST API, and manage your library of scripts in the UI or a git repo.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/scripts", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "dri": "mikermcneil", + "usualDepartment": "IT", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "demos": [ + { + "description": "A large tech company used scripts to fix issues with their security and compliance agents on workstations." + } + ], + "buzzwords": [ + "Remote script execution", + "PowerShell scripts", + "Bash scripts" + ], + "waysToUse": [ + { + "description": "Execute custom macOS scripts (client platform engineering)", + "moreInfoUrl": "https://www.hexnode.com/blogs/executing-custom-mac-scripts-via-mdm/" + }, + { + "description": "Execute custom Windows scripts (client platform engineering)", + "moreInfoUrl": "https://www.hexnode.com/blogs/executing-custom-windows-scripts-via-mdm/" + }, + { + "description": "Use PowerShell scripts on Windows devices", + "moreInfoUrl": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" + }, + { + "description": "Run PowerShell scripts for remediations (security engineering)", + "moreInfoUrl": "https://learn.microsoft.com/en-us/mem/intune/fundamentals/powershell-scripts-remediation" + }, + { + "description": "Download and run remediation scripts", + "moreInfoUrl": "https://help.zscaler.com/deception/downloading-and-running-remediation-script" + }, + { + "description": "Deploy custom scripts", + "moreInfoUrl": "https://scalefusion.com/custom-scripting" + }, + { + "description": "Run scripts on online/offline hosts", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/15529" + }, + { + "description": "Only maintainers and admins can run scripts.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/19055" + } + ], + "name": "Script execution" + }, + { + "industryName": "Device remediation", + "description": "Use Fleet Policies to detect the device state. Automate remediations for issues or allow users to remediate problems in self-service.", + "documentationUrl": "https://fleetdm.com/securing/end-user-self-remediation", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Send software vulnerability emails to end users to encourage self-remediation." + } + ], + "name": "Device remediation" + }, + { + "industryName": "Maintenance windows", + "friendlyName": "Fleet in your calendar", + "description": "Create a calendar event to auto-remediate failing policies when your end users are free.", + "documentationUrl": "https://github.com/fleetdm/fleet/issues/17230", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "isExperimental": "yes", + "productCategories": [ + "Device management", + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Maintenance windows" + }, + { + "industryName": "Send lock and wipe commands", + "description": "Secure your devices with remote lock and wipe commands if lost or stolen.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/mdm-commands", + "waysToUse": [ + { + "description": "High-level remote lock for macOS, Windows, and Linux.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/9949" + }, + { + "description": "High-level remote wipe for macOS, Windows, and Linux.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/9951" + } + ], + "tier": "Premium", + "jamfProHasFeature": "appleOnly", + "jamfProtectHasFeature": "no", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Send lock and wipe commands" + }, + { + "industryName": "Queries", + "description": "Scheduled or saved queries with optional AI-generated descriptions, and, live queries for real-time data collection.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/fleet-ui", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "demos": [ + { + "description": "A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers.", + "moreInfoUrl": "https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6" + } + ], + "name": "Queries" + }, + { + "industryName": "Query performance monitoring", + "documentationUrl": "https://fleetdm.com/docs/get-started/faq#will-fleet-slow-down-my-servers-what-about-my-employee-laptops", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "demos": [ + { + "description": "A top software company needed to understand the performance impact of osquery queries before running them on all of their production Linux servers.", + "moreInfoUrl": "https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg" + }, + { + "description": "A top software company wanted to detect regressions when adding/changing queries and fail builds if queries were too expensive.", + "moreInfoUrl": "https://docs.google.com/document/d/1WzMc8GJCRU6tTBb6gLsSTzFysqtXO8CtP2sXMPKgYSk/edit?disco=AAAA6xuVxGg" + } + ], + "waysToUse": [ + { + "description": "Monitor performance for automated queries." + }, + { + "description": "Monitor performance for live queries.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/467" + } + ], + "name": "Query performance monitoring" + }, + { + "industryName": "Custom tables", + "friendlyName": "Add tables to osquery with extensions", + "description": "Create your own osquery tables, extensions & automatic table configurations or disable existing tables to maintain PII or privacy.", + "documentationUrl": "https://fleetdm.com/docs/configuration/agent-configuration#extensions", + "moreInfoUrl": "https://github.com/trailofbits/osquery-extensions/blob/3df2b72ad78549e25344c79dbc9bce6808c4d92a/README.md#extensions", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "name": "Custom tables" + }, + { + "industryName": "Remote settings", + "description": "Configure agent options remotely, over the air. (Includes osquery config, and osquery startup flags.).", + "documentationUrl": "https://fleetdm.com/docs/configuration/agent-configuration", + "moreInfoUrl": "https://github.com/fleetdm/fleet/issues/13825", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "Security", + "name": "Remote settings" + }, + { + "industryName": "Teams", + "friendlyName": "Manage different endpoints differently", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/segment-hosts", + "description": "Teams are what Fleet calls baselines, kinda like security groups or images. Every host in a team matches the same baseline, with minor exceptions. This makes it faster and less risky to maintain computers, leading to faster timelines and fewer tickets.", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "waysToUse": [ + { + "description": "Automate remediation for different applications with different security postures (cloud security engineering)" + } + ], + "name": "Teams" + }, + { + "industryName": "Labels", + "documentationUrl": "https://fleetdm.com/docs/rest-api/rest-api#add-label", + "friendlyName": "Filter hosts using SQL", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "name": "Labels" + }, + { + "industryName": "Policies", + "description": "A policy is a specific “yes” or “no” query. Use policies to manage security compliance in your organization.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/fleet-ui", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "demos": [ + { + "description": "A top financial services company needed to set up rolling deployments for changes to osquery agents running on their production servers.", + "moreInfoUrl": "https://docs.google.com/document/d/1UdzZMyBLbs9SUXfSXN2x2wZQCbjZZUetYlNWH6-ryqQ/edit#heading=h.2lh6ehprpvl6" + } + ], + "waysToUse": [ + { + "description": "Trigger a workflow based on a failing policy", + "moreInfoUrl": "https://fleetdm.com/docs/using-fleet/automations#policy-automations" + } + ], + "name": "Policies" + }, + { + "industryName": "File integrity monitoring (FIM)", + "friendlyName": "Detect changes to critical files", + "description": "Specify files to monitor for changes or deletions, then log those events to your SIEM or data lake, including key information such as filepath and checksum.", + "documentationUrl": "https://fleetdm.com/guides/osquery-evented-tables-overview#file-integrity-monitoring-fim", + "screenshotSrc": "", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "dri": "mikermcneil", + "demos": [ + { + "description": "A top gaming company needed a way to monitor critical files on production Debian servers.", + "quote": "The FIM features are kind of a top priority.", + "moreInfoUrl": "https://docs.google.com/document/d/1pE9U-1E4YDiy6h4TorszrTOiFAauFiORikSUFUqW7Pk/edit" + } + ], + "buzzwords": [ + "File integrity monitoring (FIM)", + "Host-based intrusion detection system (HIDS)", + "Anomaly detection" + ], + "waysToUse": [ + { + "description": "Monitor critical files on production Debian servers" + }, + { + "description": "Detect anomalous filesystem activity", + "moreInfoUrl": "https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring" + }, + { + "description": "Detect unintended changes", + "moreInfoUrl": "https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring" + }, + { + "description": "Verify update status and monitor system health", + "moreInfoUrl": "https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring" + }, + { + "description": "Meet compliance mandates", + "moreInfoUrl": "https://www.beyondtrust.com/resources/glossary/file-integrity-monitoring" + } + ], + "name": "File integrity monitoring (FIM)" + }, + { + "industryName": "File carving", + "description": "Write the results of complex queries to AWS S3.", + "documentationUrl": "https://fleetdm.com/docs/configuration/fleet-server-configuration#s-3-file-carving-backend", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "File carving" + }, + { + "industryName": "Binary authorization", + "friendlyName": "Restrict what programs can run, and what files running programs can access.", + "description": null, + "documentationUrl": null, + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "dri": "mikermcneil", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "comingSoonOn": "2025-06-30", + "buzzwords": [ + "Mandatory Access Control (MAC)", + "Privilege confinement", + "Binary authorization", + "Santa", + "Binary allowlisting", + "Binary whitelisting" + ], + "demos": [ + { + "description": null, + "moreInfoUrl": null + } + ], + "waysToUse": [ + { + "description": "Confine programs to a limited set of resources." + }, + { + "description": "Report on AppArmor events", + "moreInfoUrl": "https://fleetdm.com/tables/apparmor_events" + }, + { + "description": "Confine programs according to a set of rules that specify which files a program can access.", + "moreInfoUrl": "https://wiki.debian.org/AppArmor" + }, + { + "description": "Proactively protect the system against both known and unknown vulnerabilities." + } + ], + "name": "Binary authorization", + "comingSoon": true + }, + { + "industryName": "Reporting", + "description": "Generate reports based on searchable device attributes", + "documentationUrl": "https://fleetdm.com/docs/rest-api/rest-api#get-query-report", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "IT", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Reporting" + }, + { + "industryName": "Incident response", + "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": "https://fleetdm.com/securing/how-osquery-can-help-cyber-responders#simplifying-endpoint-visibility-with-osquery-and-fleet", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "dri": "mikermcneil", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "buzzwords": [], + "demos": [ + { + "description": null, + "moreInfoUrl": null + } + ], + "waysToUse": [ + { + "description": null + } + ], + "name": "Incident response" + }, + { + "industryName": "Custom logging", + "description": "Flexible, configurable logging destinations (AWS Kinesis, Lambda, GCP, Kafka).", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/log-destinations#log-destinations", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Devices" + ], + "buzzwords": [ + "Real-time export", + "Ship logs" + ], + "name": "Custom logging" + }, + { + "industryName": "Malware detection (YARA/custom IoCs)", + "friendlyName": "Scan files for zero days and malware signatures", + "description": "Use YARA signatures to report and trigger automations when zero days, malware, or unexpected files are detected on a host.", + "documentationUrl": "https://fleetdm.com/tables/yara", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "dri": "mikermcneil", + "usualDepartment": "Security", + "productCategories": [ + "Endpoint operations", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "buzzwords": [ + "YARA scanning", + "Cyber Threat Intelligence (CTI)", + "Indicators of compromise (IOCs)", + "Antivirus (AV)", + "Endpoint protection platform (EPP)", + "Endpoint detection and response (EDR)", + "Malware detection", + "Signature-based malware detection", + "Malware scanning", + "Malware analysis", + "Anomaly detection" + ], + "demos": [ + { + "description": "A top media company used Fleet policies with YARA rules to continuously scan host filesystems for malware signatures provided by internal and external threat intelligence teams.", + "moreInfoUrl": null + } + ], + "waysToUse": [ + { + "description": "Detect suspicious bytecode in JAR files" + }, + { + "description": "Identify suspicious patterns in binaries using YARA signatures" + }, + { + "description": "Continuously scan host filesystems for malware signatures.", + "moreInfoUrl": "https://yara.readthedocs.io/en/stable/writingrules.html" + }, + { + "description": "Monitor for relevent filesystem changes (YARA events) and on-demand YARA signature scans.", + "moreInfoUrl": "https://osquery.readthedocs.io/en/stable/deployment/yara/" + }, + { + "description": "Use YARA for malware detection", + "moreInfoUrl": "https://www.cisa.gov/sites/default/files/FactSheets/NCCIC%20ICS_FactSheet_YARA_S508C.pdf" + }, + { + "description": "Scan for indicators of compromise (IoC) for common malware.", + "moreInfoUrl": "https://github.com/Cisco-Talos/osquery_queries" + }, + { + "description": "Analyze malware using data from osquery, such as endpoint certificates and launch daemons (launchd).", + "moreInfoUrl": "https://medium.com/hackernoon/malware-analysis-using-osquery-part-3-9dc805b67d16" + }, + { + "description": "Detect persistent malware (e.g. WireLurker) in endpoints by generating simple policies that search for their static indicators of compromise (IoCs).", + "moreInfoUrl": "https://osquery.readthedocs.io/en/stable/deployment/anomaly-detection/" + }, + { + "description": "Run a targeted YARA scan with osquery as a lightweight approach to scan anything on a host filesystem, with minimal performance impact. Unlike full system YARA scans which consume considerable CPU resources, an equivalent YARA scan targeted in Fleet can be 8x cheaper (CPU %).", + "moreInfoUrl": "https://www.tripwire.com/state-of-security/signature-socket-based-malware-detection-osquery-yara" + } + ], + "name": "Malware detection (YARA/custom IoCs)" + }, + { + "industryName": "Continuous scanning", + "friendlyName": "Detect vulnerable software", + "documentationUrl": "https://fleetdm.com/vulnerability-management", + "productCategories": [ + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "usualDepartment": "Security", + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "buzzwords": [ + "Stakeholder-specific vulnerability categorization (SSVC)", + "Continuous scanning", + "Continuous vulnerability scanning", + "Risk-based vulnerability management" + ], + "waysToUse": [ + { + "description": "Use an SSVC decision tree model to prioritize relevant vulnerabilities into four possible decisions: \"Track\", \"Track*\", \"Attend\", and \"Act\".", + "moreInfoUrl": "https://www.cisa.gov/stakeholder-specific-vulnerability-categorization-ssvc" + }, + { + "description": "Balint Fazakas: I think what offers a better use of CVSS if you break it down to vectors. You may find that a DoS (High Availability Impact) not as relevant for you, or equally a vulnerability requiring user interaction has a very low likelihood of exploit in another scenario. If you want to fine tune your SSVC, it worth using the vectors you care about instead of the score itself. But ultimately you would want to read the description of the vulnerabilities to determine the risk they are posing to your environment. SSVC can assist you to do that in a more efficient way.", + "moreInfoUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7162614115025215488?commentUrn=urn%3Ali%3Acomment%3A%28activity%3A7162614115025215488%2C7162681703918985216%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287162681703918985216%2Curn%3Ali%3Aactivity%3A7162614115025215488%29" + }, + { + "description": "Melissa Bischoping: CVSS is never enough to contextualize the urgency or risk of a vulnerability in your environment. It is one metric that needs to be part of an overall risk calculus, but a CVSS of 6 can be a greater threat in your organization than a CVSS of 10 based on the environmental variables and mitigations. Only two 10.0s here, but several lower severity that are resulting in high-impact breaches. Getting a handle on managing that public facing infrastructure and being able to rapidly patch the apps and devices with such exposure needs to be part of an overall plan, but must go hand in hand with mitigations and layers of a zero trust design. CVSS isn’t the sole determination of risk, it’s only one partial piece of data to understand the impact of a vulnerability if exploited.", + "moreInfoUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7162614115025215488?commentUrn=urn%3Ali%3Acomment%3A%28activity%3A7162614115025215488%2C7162629486344159232%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287162629486344159232%2Curn%3Ali%3Aactivity%3A7162614115025215488%29" + } + ], + "demos": [ + { + "description": "A top gaming company wanted to replace Qualys for infrastructure vulnerability detection.", + "quote": "So we have some stuff today through Qualys, but it's just not very good. A lot of it is...it's just really noisy. I'm trying to find out specifically, actually what packages are installed where, and then the ability to live query them.", + "moreInfoUrl": "https://docs.google.com/document/d/1JWtRsW1FUTCkZEESJj9-CvXjLXK4219by-C6vvVVyBY/edit" + }, + { + "description": "One of the world's largest, top transportation companies uses Fleet's API to email relevant, actually-installed vulnerabilities to responsible teams so they can fix them.", + "moreInfoUrl": "https://docs.google.com/document/d/1oeCmT077o_5nxzLhnxs7kcg_4Qn1Pn1F5zx10nQOAp8/edit" + } + ], + "name": "Continuous scanning" + }, + { + "industryName": "Vulnerability scores", + "friendlyName": "EPSS and CVSS", + "documentationUrl": "https://fleetdm.com/vulnerability-management", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "usualDepartment": "Security", + "productCategories": [ + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "buzzwords": [ + "Risk scores", + "Cyber risk", + "Risk reduction", + "Security operations effectiveness", + "Peer benchmarking", + "Security program effectiveness", + "Risk-based exposure scoring", + "Threat context", + "Cyber exposure", + "Exposure quantification and benchmarking", + "Optimize security investments", + "Vulnerability assessment" + ], + "demos": [ + { + "description": "Fleet enables a more modern, threat-first prioritization approach to vulnerability management.", + "quote": "In reality, across our inventory of devices, it's unlikely to ever be exploited. I'd rather do that legwork on my team and then go and ask and prioritize work on these infrastructure teams that are already busy with things that could or could not be vulnerable. Being able to be more exact allows us to go to these teams less, which saves everybody time.", + "moreInfoUrl": "https://www.youtube.com/watch?v=G5Ry_vQPaYc&t=131s" + } + ], + "waysToUse": [ + { + "description": "By leveraging EPSS (Exploit Prediction Scoring System), security professionals gain insight on the true risk behind rated CVEs." + }, + { + "description": "An Introduction to EPSS, The Exploit Prediction Scoring System" + }, + { + "moreInfoUrl": "https://www.youtube.com/watch?v=vw1RlZCSRcQ" + }, + { + "description": "By extracting metadata from the National Vulnerability Database (NVD) and Microsoft Security Response Center (MSRC), we can determine which version of software is no longer vulnerable." + } + ], + "name": "Vulnerability scores" + }, + { + "industryName": "CISA KEVs", + "description": "Known exploited vulnerabilities", + "documentationUrl": "https://fleetdm.com/vulnerability-management", + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "yes", + "usualDepartment": "Security", + "productCategories": [ + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "demos": [ + { + "description": null, + "moreInfoUrl": null + } + ], + "waysToUse": [ + { + "description": "Help teams work on vulnerabilities that have actually been exploited (CISA KEVs) or have a high probability of being exploited (EPSS), or whatever is important in your environment." + }, + { + "description": "Use CISA KEVs for vulnerability management" + }, + { + "moreInfoUrl": "https://www.youtube.com/watch?v=Z3mw2oxssYk" + } + ], + "name": "CISA KEVs" + }, + { + "industryName": "Asset discovery", + "documentationUrl": null, + "tier": "Premium", + "comingSoonOn": "2025-06-30", + "usualDepartment": "Security", + "productCategories": [ + "Vulnerability management" + ], + "pricingTableCategories": [ + "Devices" + ], + "name": "Asset discovery", + "comingSoon": true + }, + { + "industryName": "REST API", + "friendlyName": "Automate any feature", + "description": null, + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "documentationUrl": "https://fleetdm.com/docs/rest-api/rest-api", + "screenshotSrc": null, + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "dri": "rachaelshaw", + "name": "REST API" + }, + { + "industryName": "Webhooks", + "friendlyName": "Automations", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/automations#automations", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Webhooks" + }, + { + "industryName": "Grant API-only access", + "description": "Grant API-only access to accounts exclusively for automation.", + "documentationUrl": "https://fleetdm.com/docs/using-fleet/fleetctl-cli#using-fleetctl-with-an-api-only-user", + "productCategories": [ + "Endpoint operations" + ], + "pricingTableCategories": [ + "Integrations" + ], + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Grant API-only access" + }, + { + "industryName": "Single sign on", + "description": "SSO, SAML", + "documentationUrl": "https://fleetdm.com/docs/deploy/single-sign-on-sso#single-sign-on-sso", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Single sign on" + }, + { + "industryName": "Automatic user creation (JIT, SCIM)", + "description": "Auto-create and manipulate Fleet users from Okta, etc with just-in-time (JIT) provisioning.", + "documentationUrl": "https://fleetdm.com/docs/deploy/single-sign-on-sso#just-in-time-jit-user-provisioning", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "no", + "name": "Automatic user creation (JIT, SCIM)" + }, + { + "industryName": "Third-party automation", + "friendlyName": "Borrow off-the-shelf tactics from the community", + "documentationUrl": "https://fleetdm.com/integrations", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "description": "Plug Fleet into other frameworks and tools like Tines, Snowflake, Terraform, Chronicle, Jira, Zendesk, etc", + "moreInfoUrl": "https://fleetdm.com/integrations", + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "waysToUse": [ + { + "description": "(ActiveDirectory) Know who opened your computer and check their device posture before you let them log into anything." + }, + { + "description": "(Ansible) Easily issue MDM commands and standardize data across operating systems." + }, + { + "description": "(AWS) Deploy your own self-managed Fleet in any AWS environment in minutes." + }, + { + "description": "(Azure) Deploy your own self-managed Fleet in the Microsoft Cloud in minutes." + }, + { + "description": "(Chef) Easily issue MDM commands and standardize data across operating systems." + }, + { + "description": "(Elastic) Ingest osquery data and monitor for important changes or events." + }, + { + "description": "(GitHub) Version control using git, enabling collaboration and a GitOps workflow." + }, + { + "description": "(GitLab) Version control using git, enabling collaboration and a GitOps workflow." + }, + { + "description": "(Chronicle) Ingest osquery data and monitor for important changes or events." + }, + { + "description": "(Google Cloud) Deploy your own self-managed Fleet in any GCP environment in minutes." + }, + { + "description": "(Munki) Easily issue MDM commands and standardize data across operating systems." + }, + { + "description": "(Okta) Know who opened your computer and check their device posture before you let them log into anything." + }, + { + "description": "(Snowflake) Ingest osquery data and monitor for important changes or events." + }, + { + "description": "(Splunk) Ingest osquery data and monitor for important changes or events." + }, + { + "description": "(Tines) Build custom workflows that trigger in various situations." + }, + { + "description": "(Webhooks) Configure automations that send webhooks to specific URLs when Fleet detects changes to host, policy, and CVE statuses." + }, + { + "description": "(Zendesk) Automatically create Zendesk tickets in various situations." + }, + { + "description": "(Jira) Automatically create Jira tickets in various situations, including exporting vulnerabilities to Jira and syncing tickets." + } + ], + "buzzwords": [ + "Snowflake", + "Okta", + "Tines", + "Splunk", + "Elastic", + "AWS", + "ActiveDirectory", + "Ansible", + "GitHub", + "GitLab", + "Chronicle", + "Google Cloud", + "Munki", + "Vanta", + "Chef", + "Zendesk", + "Jira" + ], + "name": "Third-party automation" + }, + { + "industryName": "Third-party orchestration", + "friendlyName": "Borrow off-the-shelf tactics from legendary brands", + "documentationUrl": "https://fleetdm.com/integrations", + "description": "Plug Fleet into other frameworks and tools like Puppet, Vanta, etc.", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "usualDepartment": "IT", + "moreInfoUrl": "https://fleetdm.com/integrations", + "tier": "Premium", + "waysToUse": [ + { + "description": "(Vanta) Trigger a workflow based on a failing policy." + }, + { + "description": "(Puppet) Easily issue MDM commands, standardize data across operating systems, and map macOS+Windows settings to computers with the Puppet module." + }, + { + "description": "(Torq) Build custom workflows that trigger in various situations." + }, + { + "description": "(Custom IdP) Manage access to Fleet single sign-on (SSO) through any IdP (using SAML)." + } + ], + "buzzwords": [ + "Vanta", + "Puppet", + "Custom IdP" + ], + "name": "Third-party orchestration" + }, + { + "industryName": "Munki compatibility + visibility", + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "usualDepartment": "IT", + "productCategories": [ + "Device management" + ], + "pricingTableCategories": [ + "Integrations" + ], + "name": "Munki compatibility + visibility" + }, + { + "industryName": "Open-source issue tracker (GitHub)", + "documentationUrl": "https://fleetdm.com/support", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Support" + ], + "tier": "Free", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "name": "Open-source issue tracker (GitHub)" + }, + { + "industryName": "Community Slack channel", + "documentationUrl": "https://fleetdm.com/support", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Support" + ], + "tier": "Free", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Community Slack channel" + }, + { + "industryName": "Unlimited email support (confidential)", + "documentationUrl": "https://fleetdm.com/support", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Support" + ], + "tier": "Premium", + "jamfProHasFeature": "yes", + "jamfProtectHasFeature": "yes", + "name": "Unlimited email support (confidential)" + }, + { + "industryName": "Phone and video call support", + "documentationUrl": "https://fleetdm.com/support", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "pricingTableCategories": [ + "Support" + ], + "tier": "Premium", + "jamfProHasFeature": "no", + "jamfProtectHasFeature": "no", + "name": "Phone and video call support" + } + ], + "markdownPages": [ + { + "url": "/docs", + "title": "Readme.md", + "lastModifiedAt": 1726839803427, + "htmlId": "docs--readme--51292620cf", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "README.md", + "meta": {} + }, + { + "url": "/docs/rest-api/rest-api", + "title": "REST API", + "lastModifiedAt": 1726839804830, + "htmlId": "docs--rest-api--aa8babd202", + "pageOrderInSectionPath": 30, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "REST API/rest-api.md", + "meta": { + "description": "Documentation for Fleet's REST API. See example requests and responses for each API endpoint." + } + }, + { + "url": "/docs/configuration/agent-configuration", + "title": "Agent configuration", + "lastModifiedAt": 1726839804835, + "htmlId": "docs--agent-configuration--ac988306ab", + "pageOrderInSectionPath": 300, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Configuration/agent-configuration.md", + "meta": { + "description": "Learn how to use configuration files and the fleetctl command line tool to configure agent options." + } + }, + { + "url": "/docs/configuration", + "title": "Configuration", + "lastModifiedAt": 1726839804836, + "htmlId": "docs--readme--71f5513034", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Configuration/README.md", + "meta": {} + }, + { + "url": "/docs/configuration/fleet-server-configuration", + "title": "Fleet server configuration", + "lastModifiedAt": 1726839804850, + "htmlId": "docs--fleet-server-configu--51d934dc8a", + "pageOrderInSectionPath": 100, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Configuration/fleet-server-configuration.md", + "meta": { + "description": "This page includes resources for configuring the Fleet binary, managing osquery configurations, and running with systemd." + } + }, + { + "url": "/docs/configuration/yaml-files", + "title": "YAML files", + "lastModifiedAt": 1726839804856, + "htmlId": "docs--yaml-files--1c08b93d5e", + "pageOrderInSectionPath": 1500, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Configuration/yaml-files.md", + "meta": { + "description": "Reference documentation for Fleet's GitOps workflow. See examples and configuration options." + } + }, + { + "url": "/docs/rest-api", + "title": "REST API", + "lastModifiedAt": 1726839804857, + "htmlId": "docs--readme--1c430dc120", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "REST API/README.md", + "meta": {} + }, + { + "url": "/docs/deploy/reference-architectures", + "title": "Reference architectures", + "lastModifiedAt": 1726839804860, + "htmlId": "docs--reference-architectu--1e6f63e559", + "pageOrderInSectionPath": 400, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Deploy/Reference-Architectures.md", + "meta": { + "description": "An opinionated view of running Fleet in a production environment, and configuration strategies to enable high availability." + } + }, + { + "url": "/docs/deploy", + "title": "Deploy", + "lastModifiedAt": 1726839804861, + "htmlId": "docs--readme--926e990cf4", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Deploy/README.md", + "meta": { + "description": "An overview of the deployment documentation for Fleet." + } + }, + { + "url": "/docs/deploy/deploy-fleet", + "title": "Deploy Fleet", + "lastModifiedAt": 1726839804863, + "htmlId": "docs--deploy-fleet--82212f6ffe", + "pageOrderInSectionPath": 100, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Deploy/deploy-fleet.md", + "meta": { + "description": "Learn how to easily deploy Fleet on Render or AWS with Terraform." + } + }, + { + "url": "/docs/deploy/single-sign-on-sso", + "title": "Single sign-on (SSO)", + "lastModifiedAt": 1726839804865, + "htmlId": "docs--single-sign-on-sso--89a4f43390", + "pageOrderInSectionPath": 200, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Deploy/single-sign-on-sso.md", + "meta": { + "description": "Learn how to configure single sign-on (SSO)" + } + }, + { + "url": "/docs/get-started/faq", + "title": "FAQ", + "lastModifiedAt": 1726839804868, + "htmlId": "docs--faq--abab6eff91", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Get started/FAQ.md", + "meta": { + "description": "Commonly asked questions and answers about deployment from the Fleet community." + } + }, + { + "url": "/docs/get-started", + "title": "Get started", + "lastModifiedAt": 1726839804869, + "htmlId": "docs--readme--3568e93d97", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Get started/README.md", + "meta": {} + }, + { + "url": "/docs/get-started/anatomy", + "title": "Anatomy", + "lastModifiedAt": 1726839804869, + "htmlId": "docs--anatomy--1f83ca9de5", + "pageOrderInSectionPath": 200, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Get started/anatomy.md", + "meta": {} + }, + { + "url": "/docs/get-started/why-fleet", + "title": "Why Fleet", + "lastModifiedAt": 1726839804870, + "htmlId": "docs--why-fleet--9ea776ea58", + "pageOrderInSectionPath": 100, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Get started/why-fleet.md", + "meta": {} + }, + { + "url": "/docs/deploy/upgrading-fleet", + "title": "Upgrading Fleet", + "lastModifiedAt": 1726839804871, + "htmlId": "docs--upgrading-fleet--a39ae08550", + "pageOrderInSectionPath": 300, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Deploy/Upgrading-Fleet.md", + "meta": { + "description": "Learn how to upgrade your Fleet instance to the latest version." + } + }, + { + "url": "/docs/get-started/tutorials-and-guides", + "title": "Tutorials and guides", + "lastModifiedAt": 1726839804872, + "htmlId": "docs--tutorials-and-guides--27a7cc6bcf", + "pageOrderInSectionPath": 300, + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Get started/tutorials-and-guides.md", + "meta": { + "description": "Links to deployment tutorials and guides for using Fleet." + } + }, + { + "url": "/docs/using-fleet", + "title": "Using Fleet", + "lastModifiedAt": 1726839804873, + "htmlId": "docs--readme--d3ac87c2d1", + "docNavCategory": "Uncategorized", + "sectionRelativeRepoPath": "Using Fleet/README.md", + "meta": {} + }, + { + "url": "/handbook", + "title": "Readme.md", + "lastModifiedAt": 1726839804876, + "htmlId": "handbook--readme--58c6582576", + "sectionRelativeRepoPath": "README.md", + "meta": { + "maintainedBy": "mikermcneil" + }, + "linksForHandbookIndex": [ + { + "headingText": "Introduction", + "hashLink": "/handbook#introduction" + } + ] + }, + { + "url": "/handbook/company", + "title": "🔭 Company", + "lastModifiedAt": 1726839804878, + "htmlId": "handbook--readme--e464663acc", + "sectionRelativeRepoPath": "company/README.md", + "meta": { + "maintainedBy": "mikermcneil" + }, + "linksForHandbookIndex": [ + { + "headingText": "Purpose", + "hashLink": "/handbook/company#purpose" + }, + { + "headingText": "Culture", + "hashLink": "/handbook/company#culture" + }, + { + "headingText": "Open positions", + "hashLink": "/handbook/company#open-positions" + }, + { + "headingText": "Values", + "hashLink": "/handbook/company#values" + }, + { + "headingText": "History", + "hashLink": "/handbook/company#history" + }, + { + "headingText": "Org chart", + "hashLink": "/handbook/company#org-chart" + }, + { + "headingText": "Advisors", + "hashLink": "/handbook/company#advisors" + } + ] + }, + { + "url": "/handbook/company/handbook", + "title": "Handbook", + "lastModifiedAt": 1726839804879, + "htmlId": "handbook--handbook--9ae510ce56", + "sectionRelativeRepoPath": "company/handbook.md", + "meta": { + "maintainedBy": "mike-j-thomas" + }, + "linksForHandbookIndex": [ + { + "headingText": "Contributing to the handbook", + "hashLink": "/handbook/company/handbook#contributing-to-the-handbook" + } + ] + }, + { + "url": "/handbook/company/communications", + "title": "🛰️ Communications", + "lastModifiedAt": 1726839804891, + "htmlId": "handbook--communications--f0d5a4a053", + "sectionRelativeRepoPath": "company/communications.md", + "meta": { + "maintainedBy": "mikermcneil" + }, + "linksForHandbookIndex": [ + { + "headingText": "All hands", + "hashLink": "/handbook/company/communications#all-hands" + }, + { + "headingText": "Strategy", + "hashLink": "/handbook/company/communications#strategy" + }, + { + "headingText": "Directly responsible individuals (DRIs)", + "hashLink": "/handbook/company/communications#directly-responsible-individuals-dr-is" + }, + { + "headingText": "Tech stack admins", + "hashLink": "/handbook/company/communications#tech-stack-admins" + }, + { + "headingText": "Fleetdm.com", + "hashLink": "/handbook/company/communications#fleetdm-com" + }, + { + "headingText": "Marketing programs", + "hashLink": "/handbook/company/communications#marketing-programs" + }, + { + "headingText": "Meetings", + "hashLink": "/handbook/company/communications#meetings" + }, + { + "headingText": "Skip-level 1:1 meetings ", + "hashLink": "/handbook/company/communications#skip-level-1-1-meetings" + }, + { + "headingText": "Zoom", + "hashLink": "/handbook/company/communications#zoom" + }, + { + "headingText": "Levels of confidentiality", + "hashLink": "/handbook/company/communications#levels-of-confidentiality" + }, + { + "headingText": "Google Drive", + "hashLink": "/handbook/company/communications#google-drive" + }, + { + "headingText": "Email relays", + "hashLink": "/handbook/company/communications#email-relays" + }, + { + "headingText": "Slack", + "hashLink": "/handbook/company/communications#slack" + }, + { + "headingText": "GitHub", + "hashLink": "/handbook/company/communications#git-hub" + }, + { + "headingText": "High priority user stories and bugs", + "hashLink": "/handbook/company/communications#high-priority-user-stories-and-bugs" + }, + { + "headingText": "Figma", + "hashLink": "/handbook/company/communications#figma" + }, + { + "headingText": "Spending company money", + "hashLink": "/handbook/company/communications#spending-company-money" + }, + { + "headingText": "Travel", + "hashLink": "/handbook/company/communications#travel" + }, + { + "headingText": "SOC 2", + "hashLink": "/handbook/company/communications#soc-2" + }, + { + "headingText": "Vendor questionnaires ", + "hashLink": "/handbook/company/communications#vendor-questionnaires" + }, + { + "headingText": "Getting a contract signed", + "hashLink": "/handbook/company/communications#getting-a-contract-signed" + }, + { + "headingText": "Getting a contract reviewed", + "hashLink": "/handbook/company/communications#getting-a-contract-reviewed" + }, + { + "headingText": "Trust", + "hashLink": "/handbook/company/communications#trust" + }, + { + "headingText": "Benefits", + "hashLink": "/handbook/company/communications#benefits" + }, + { + "headingText": "Compensation", + "hashLink": "/handbook/company/communications#compensation" + }, + { + "headingText": "Team member onboarding", + "hashLink": "/handbook/company/communications#team-member-onboarding" + }, + { + "headingText": "Performance feedback", + "hashLink": "/handbook/company/communications#performance-feedback" + }, + { + "headingText": "Equipment", + "hashLink": "/handbook/company/communications#equipment" + }, + { + "headingText": "Writing", + "hashLink": "/handbook/company/communications#writing" + }, + { + "headingText": "Writing in Fleet-flavored Markdown", + "hashLink": "/handbook/company/communications#writing-in-fleet-flavored-markdown" + }, + { + "headingText": "Things", + "hashLink": "/handbook/company/communications#things" + }, + { + "headingText": "Commonly used terms", + "hashLink": "/handbook/company/communications#commonly-used-terms" + } + ] + }, + { + "url": "/handbook/company/leadership", + "title": "🛠️ Leadership", + "lastModifiedAt": 1726839804898, + "htmlId": "handbook--leadership--7d8a02ee64", + "sectionRelativeRepoPath": "company/leadership.md", + "meta": { + "maintainedBy": "mikermcneil" + }, + "linksForHandbookIndex": [ + { + "headingText": "CEO flaws", + "hashLink": "/handbook/company/leadership#ceo-flaws" + }, + { + "headingText": "Contact the CEO", + "hashLink": "/handbook/company/leadership#contact-the-ceo" + }, + { + "headingText": "CEO responsibilities", + "hashLink": "/handbook/company/leadership#ceo-responsibilities" + }, + { + "headingText": "Outline of departmental page structure", + "hashLink": "/handbook/company/leadership#outline-of-departmental-page-structure" + }, + { + "headingText": "Key reviews", + "hashLink": "/handbook/company/leadership#key-reviews" + }, + { + "headingText": "Hiring", + "hashLink": "/handbook/company/leadership#hiring" + }, + { + "headingText": "CEO shadow program", + "hashLink": "/handbook/company/leadership#ceo-shadow-program" + }, + { + "headingText": "Tracking hours", + "hashLink": "/handbook/company/leadership#tracking-hours" + }, + { + "headingText": "Communicating departures", + "hashLink": "/handbook/company/leadership#communicating-departures" + }, + { + "headingText": "Changing someone's position", + "hashLink": "/handbook/company/leadership#changing-someone-s-position" + }, + { + "headingText": "Delivering performance feedback", + "hashLink": "/handbook/company/leadership#delivering-performance-feedback" + } + ] + }, + { + "url": "/handbook/company/product-groups", + "title": "🛩️ Product groups", + "lastModifiedAt": 1726839804907, + "htmlId": "handbook--product-groups--44ec471e19", + "sectionRelativeRepoPath": "company/product-groups.md", + "meta": { + "maintainedBy": "lukeheath" + }, + "linksForHandbookIndex": [ + { + "headingText": "Product roadmap", + "hashLink": "/handbook/company/product-groups#product-roadmap" + }, + { + "headingText": "What are product groups?", + "hashLink": "/handbook/company/product-groups#what-are-product-groups" + }, + { + "headingText": "Current product groups", + "hashLink": "/handbook/company/product-groups#current-product-groups" + }, + { + "headingText": "Making changes", + "hashLink": "/handbook/company/product-groups#making-changes" + }, + { + "headingText": "Outages", + "hashLink": "/handbook/company/product-groups#outages" + }, + { + "headingText": "Scaling Fleet", + "hashLink": "/handbook/company/product-groups#scaling-fleet" + }, + { + "headingText": "Load testing", + "hashLink": "/handbook/company/product-groups#load-testing" + }, + { + "headingText": "Version support", + "hashLink": "/handbook/company/product-groups#version-support" + }, + { + "headingText": "Release testing", + "hashLink": "/handbook/company/product-groups#release-testing" + }, + { + "headingText": "Feature fest", + "hashLink": "/handbook/company/product-groups#feature-fest" + }, + { + "headingText": "Quality", + "hashLink": "/handbook/company/product-groups#quality" + }, + { + "headingText": "How to reach the developer on-call", + "hashLink": "/handbook/company/product-groups#how-to-reach-the-developer-on-call" + }, + { + "headingText": "Wireframes", + "hashLink": "/handbook/company/product-groups#wireframes" + }, + { + "headingText": "Meetings", + "hashLink": "/handbook/company/product-groups#meetings" + }, + { + "headingText": "Development best practices", + "hashLink": "/handbook/company/product-groups#development-best-practices" + }, + { + "headingText": "Product design conventions", + "hashLink": "/handbook/company/product-groups#product-design-conventions" + }, + { + "headingText": "Scrum at Fleet", + "hashLink": "/handbook/company/product-groups#scrum-at-fleet" + }, + { + "headingText": "Sprints", + "hashLink": "/handbook/company/product-groups#sprints" + }, + { + "headingText": "Outside contributions", + "hashLink": "/handbook/company/product-groups#outside-contributions" + } + ] + }, + { + "url": "/handbook/company/why-this-way", + "title": "💭 Why this way?", + "lastModifiedAt": 1726839804912, + "htmlId": "handbook--why-this-way--52ff9aa8d3", + "sectionRelativeRepoPath": "company/why-this-way.md", + "meta": { + "maintainedBy": "mikermcneil" + }, + "linksForHandbookIndex": [ + { + "headingText": "Why open source?", + "hashLink": "/handbook/company/why-this-way#why-open-source" + }, + { + "headingText": "Why handbook-first strategy?", + "hashLink": "/handbook/company/why-this-way#why-handbook-first-strategy" + }, + { + "headingText": "Why read documentation?", + "hashLink": "/handbook/company/why-this-way#why-read-documentation" + }, + { + "headingText": "Why the emphasis on training?", + "hashLink": "/handbook/company/why-this-way#why-the-emphasis-on-training" + }, + { + "headingText": "Why direct responsibility?", + "hashLink": "/handbook/company/why-this-way#why-direct-responsibility" + }, + { + "headingText": "Why do we use a wireframe-first approach?", + "hashLink": "/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach" + }, + { + "headingText": "Why do we use one repo?", + "hashLink": "/handbook/company/why-this-way#why-do-we-use-one-repo" + }, + { + "headingText": "Why not continuously generate REST API reference docs from javadoc-style code comments?", + "hashLink": "/handbook/company/why-this-way#why-not-continuously-generate-rest-api-reference-docs-from-javadoc-style-code-comments" + }, + { + "headingText": "Why group Slack channels?", + "hashLink": "/handbook/company/why-this-way#why-group-slack-channels" + }, + { + "headingText": "Why make work visible?", + "hashLink": "/handbook/company/why-this-way#why-make-work-visible" + }, + { + "headingText": "Why agile?", + "hashLink": "/handbook/company/why-this-way#why-agile" + }, + { + "headingText": "Why a three-week cadence?", + "hashLink": "/handbook/company/why-this-way#why-a-three-week-cadence" + }, + { + "headingText": "Why spend so much energy responding to every potential production incident?", + "hashLink": "/handbook/company/why-this-way#why-spend-so-much-energy-responding-to-every-potential-production-incident" + }, + { + "headingText": "Why make it obvious when stuff breaks?", + "hashLink": "/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks" + }, + { + "headingText": "Why keep issue templates simple?", + "hashLink": "/handbook/company/why-this-way#why-keep-issue-templates-simple" + }, + { + "headingText": "Why spend less?", + "hashLink": "/handbook/company/why-this-way#why-spend-less" + }, + { + "headingText": "Why don't we sell like everyone else?", + "hashLink": "/handbook/company/why-this-way#why-don-t-we-sell-like-everyone-else" + }, + { + "headingText": "Why does Fleet support query packs?", + "hashLink": "/handbook/company/why-this-way#why-does-fleet-support-query-packs" + }, + { + "headingText": "Why does Fleet use sentence case?", + "hashLink": "/handbook/company/why-this-way#why-does-fleet-use-sentence-case" + }, + { + "headingText": "Why not use superlatives?", + "hashLink": "/handbook/company/why-this-way#why-not-use-superlatives" + }, + { + "headingText": "Why does Fleet use \"MDM on/off\" instead of \"MDM enrolled/unenrolled\"?", + "hashLink": "/handbook/company/why-this-way#why-does-fleet-use-mdm-on-off-instead-of-mdm-enrolled-unenrolled" + }, + { + "headingText": "Why not mention the CEO in Slack threads?", + "hashLink": "/handbook/company/why-this-way#why-not-mention-the-ceo-in-slack-threads" + } + ] + }, + { + "url": "/handbook/customer-success", + "title": "🌦️ Customer Success", + "lastModifiedAt": 1726839804915, + "htmlId": "handbook--readme--f00a4291b8", + "sectionRelativeRepoPath": "customer-success/README.md", + "meta": { + "maintainedBy": "zayhanlon" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/customer-success#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/customer-success#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/customer-success#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/customer-success#rituals" + } + ] + }, + { + "url": "/handbook/engineering/debugging", + "title": "Debugging", + "lastModifiedAt": 1726839804916, + "htmlId": "handbook--debugging--72906ebdd6", + "sectionRelativeRepoPath": "engineering/Debugging.md", + "meta": { + "maintainedBy": "lukeheath", + "description": "A guide to triaging and diagnosing issues in Fleet." + }, + "linksForHandbookIndex": [ + { + "headingText": "Goals of this guide", + "hashLink": "/handbook/engineering/debugging#goals-of-this-guide" + }, + { + "headingText": "Basic data that is needed", + "hashLink": "/handbook/engineering/debugging#basic-data-that-is-needed" + }, + { + "headingText": "Triaging the issue", + "hashLink": "/handbook/engineering/debugging#triaging-the-issue" + } + ] + }, + { + "url": "/handbook/engineering/load-testing", + "title": "Load testing", + "lastModifiedAt": 1726839804917, + "htmlId": "handbook--load-testing--5fd9ee04e0", + "sectionRelativeRepoPath": "engineering/Load-testing.md", + "meta": { + "maintainedBy": "lukeheath", + "description": "This page outlines the most recent results of a semi-annual load test of the Fleet server." + }, + "linksForHandbookIndex": [ + { + "headingText": "Test parameters", + "hashLink": "/handbook/engineering/load-testing#test-parameters" + }, + { + "headingText": "Results", + "hashLink": "/handbook/engineering/load-testing#results" + }, + { + "headingText": "How we are simulating osquery", + "hashLink": "/handbook/engineering/load-testing#how-we-are-simulating-osquery" + }, + { + "headingText": "Infrastructure setup", + "hashLink": "/handbook/engineering/load-testing#infrastructure-setup" + }, + { + "headingText": "Limitations", + "hashLink": "/handbook/engineering/load-testing#limitations" + } + ] + }, + { + "url": "/handbook/engineering", + "title": "🚀 Engineering", + "lastModifiedAt": 1726839804924, + "htmlId": "handbook--readme--777ccc3e11", + "sectionRelativeRepoPath": "engineering/README.md", + "meta": { + "maintainedBy": "lukeheath" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/engineering#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/engineering#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/engineering#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/engineering#rituals" + } + ] + }, + { + "url": "/handbook/engineering/scaling-fleet", + "title": "Scaling Fleet", + "lastModifiedAt": 1726839804925, + "htmlId": "handbook--scaling-fleet--7496895e6e", + "sectionRelativeRepoPath": "engineering/scaling-fleet.md", + "meta": { + "maintainedBy": "lukeheath" + } + }, + { + "url": "/handbook/finance", + "title": "💸 Finance", + "lastModifiedAt": 1726839804931, + "htmlId": "handbook--readme--adb6ad624d", + "sectionRelativeRepoPath": "finance/README.md", + "meta": { + "maintainedBy": "jostableford" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/finance#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/finance#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/finance#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/finance#rituals" + } + ] + }, + { + "url": "/handbook/demand", + "title": "🫧 Demand", + "lastModifiedAt": 1726839804935, + "htmlId": "handbook--readme--5f95cdc89d", + "sectionRelativeRepoPath": "demand/README.md", + "meta": { + "maintainedBy": "Drew-P-Drawers" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/demand#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/demand#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/demand#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/demand#rituals" + } + ] + }, + { + "url": "/handbook/product-design", + "title": "🦢 Product design", + "lastModifiedAt": 1726839804937, + "htmlId": "handbook--readme--5ce44066f3", + "sectionRelativeRepoPath": "product-design/README.md", + "meta": { + "maintainedBy": "noahtalerman" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/product-design#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/product-design#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/product-design#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/product-design#rituals" + } + ] + }, + { + "url": "/handbook/digital-experience/application-security", + "title": "Application security", + "lastModifiedAt": 1726839804939, + "htmlId": "handbook--application-security--60a7adaa5a", + "sectionRelativeRepoPath": "digital-experience/application-security.md", + "meta": { + "description": "Explore Fleet's application security practices, including secure coding, SQL injection prevention, authentication, data encryption, access controls, and more.", + "maintainedBy": "hollidayn" + } + }, + { + "url": "/handbook/digital-experience", + "title": "🌐 Digital Experience", + "lastModifiedAt": 1726839804945, + "htmlId": "handbook--readme--7c78659bd2", + "sectionRelativeRepoPath": "digital-experience/README.md", + "meta": { + "maintainedBy": "Sampfluger88" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/digital-experience#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/digital-experience#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/digital-experience#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/digital-experience#rituals" + } + ] + }, + { + "url": "/handbook/digital-experience/security-audits", + "title": "Security audits", + "lastModifiedAt": 1726839804948, + "htmlId": "handbook--security-audits--b0d65992c5", + "sectionRelativeRepoPath": "digital-experience/security-audits.md", + "meta": { + "description": "Explanations of the latest external security audits performed on Fleet software.", + "maintainedBy": "hollidayn" + }, + "linksForHandbookIndex": [ + { + "headingText": "June 2024 penetration testing of Fleet 4.50.1", + "hashLink": "/handbook/digital-experience/security-audits#june-2024-penetration-testing-of-fleet-4-50-1" + }, + { + "headingText": "June 2023 penetration testing of Fleet 4.32 ", + "hashLink": "/handbook/digital-experience/security-audits#june-2023-penetration-testing-of-fleet-4-32" + }, + { + "headingText": "April 2022 penetration testing of Fleet 4.12 ", + "hashLink": "/handbook/digital-experience/security-audits#april-2022-penetration-testing-of-fleet-4-12" + }, + { + "headingText": "August 2021 security of Orbit auto-updater", + "hashLink": "/handbook/digital-experience/security-audits#august-2021-security-of-orbit-auto-updater" + } + ] + }, + { + "url": "/handbook/digital-experience/security-policies", + "title": "📜 Security policies", + "lastModifiedAt": 1726839804955, + "htmlId": "handbook--security-policies--96158a5cf6", + "sectionRelativeRepoPath": "digital-experience/security-policies.md", + "meta": { + "maintainedBy": "jostableford" + }, + "linksForHandbookIndex": [ + { + "headingText": "Information security policy and acceptable use policy", + "hashLink": "/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy" + }, + { + "headingText": "Access control policy", + "hashLink": "/handbook/digital-experience/security-policies#access-control-policy" + }, + { + "headingText": "Asset management policy", + "hashLink": "/handbook/digital-experience/security-policies#asset-management-policy" + }, + { + "headingText": "Business continuity and disaster recovery policy", + "hashLink": "/handbook/digital-experience/security-policies#business-continuity-and-disaster-recovery-policy" + }, + { + "headingText": "Data management policy", + "hashLink": "/handbook/digital-experience/security-policies#data-management-policy" + }, + { + "headingText": "Encryption policy", + "hashLink": "/handbook/digital-experience/security-policies#encryption-policy" + }, + { + "headingText": "Human resources security policy", + "hashLink": "/handbook/digital-experience/security-policies#human-resources-security-policy" + }, + { + "headingText": "Incident response policy", + "hashLink": "/handbook/digital-experience/security-policies#incident-response-policy" + }, + { + "headingText": "Network and system hardening standards", + "hashLink": "/handbook/digital-experience/security-policies#network-and-system-hardening-standards" + }, + { + "headingText": "Operations security and change management policy", + "hashLink": "/handbook/digital-experience/security-policies#operations-security-and-change-management-policy" + }, + { + "headingText": "Risk management policy", + "hashLink": "/handbook/digital-experience/security-policies#risk-management-policy" + }, + { + "headingText": "Secure software development and product security policy ", + "hashLink": "/handbook/digital-experience/security-policies#secure-software-development-and-product-security-policy" + }, + { + "headingText": "Security policy management policy", + "hashLink": "/handbook/digital-experience/security-policies#security-policy-management-policy" + }, + { + "headingText": "Third-party management policy", + "hashLink": "/handbook/digital-experience/security-policies#third-party-management-policy" + }, + { + "headingText": "Anti-corruption policy", + "hashLink": "/handbook/digital-experience/security-policies#anti-corruption-policy" + } + ] + }, + { + "url": "/handbook/digital-experience/vendor-questionnaires", + "title": "📃 Vendor questionnaires", + "lastModifiedAt": 1726839804956, + "htmlId": "handbook--vendor-questionnaire--46cac642a1", + "sectionRelativeRepoPath": "digital-experience/vendor-questionnaires.md", + "meta": { + "maintainedBy": "dherder" + }, + "linksForHandbookIndex": [ + { + "headingText": "Scoping", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#scoping" + }, + { + "headingText": "Application security", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#application-security" + }, + { + "headingText": "Data security", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#data-security" + }, + { + "headingText": "Service monitoring and logging", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#service-monitoring-and-logging" + }, + { + "headingText": "Encryption and key management", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#encryption-and-key-management" + }, + { + "headingText": "Governance and risk management", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#governance-and-risk-management" + }, + { + "headingText": "Business continuity", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#business-continuity" + }, + { + "headingText": "Network security", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#network-security" + }, + { + "headingText": "Privacy", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#privacy" + }, + { + "headingText": "Sub-processors", + "hashLink": "/handbook/digital-experience/vendor-questionnaires#sub-processors" + } + ] + }, + { + "url": "/handbook/digital-experience/security", + "title": "Security", + "lastModifiedAt": 1726839804965, + "htmlId": "handbook--security--585b03364d", + "sectionRelativeRepoPath": "digital-experience/security.md", + "meta": { + "maintainedBy": "hollidayn" + }, + "linksForHandbookIndex": [ + { + "headingText": "Security policies", + "hashLink": "/handbook/digital-experience/security#security-policies" + }, + { + "headingText": "Account recovery process", + "hashLink": "/handbook/digital-experience/security#account-recovery-process" + }, + { + "headingText": "How we protect end-user devices", + "hashLink": "/handbook/digital-experience/security#how-we-protect-end-user-devices" + }, + { + "headingText": "Hardware security keys", + "hashLink": "/handbook/digital-experience/security#hardware-security-keys" + }, + { + "headingText": "GitHub security", + "hashLink": "/handbook/digital-experience/security#git-hub-security" + }, + { + "headingText": "Google Workspace security", + "hashLink": "/handbook/digital-experience/security#google-workspace-security" + }, + { + "headingText": "Vulnerability management", + "hashLink": "/handbook/digital-experience/security#vulnerability-management" + }, + { + "headingText": "Trust report", + "hashLink": "/handbook/digital-experience/security#trust-report" + }, + { + "headingText": "Securtiy audits", + "hashLink": "/handbook/digital-experience/security#securtiy-audits" + }, + { + "headingText": "Application security", + "hashLink": "/handbook/digital-experience/security#application-security" + } + ] + }, + { + "url": "/handbook/sales", + "title": "🐋 Sales", + "lastModifiedAt": 1726839804968, + "htmlId": "handbook--readme--4fe57c451a", + "sectionRelativeRepoPath": "sales/README.md", + "meta": { + "maintainedBy": "alexmitchelliii" + }, + "linksForHandbookIndex": [ + { + "headingText": "Team", + "hashLink": "/handbook/sales#team" + }, + { + "headingText": "Contact us", + "hashLink": "/handbook/sales#contact-us" + }, + { + "headingText": "Responsibilities", + "hashLink": "/handbook/sales#responsibilities" + }, + { + "headingText": "Rituals", + "hashLink": "/handbook/sales#rituals" + } + ] + }, + { + "url": "/engineering/tips-for-github-actions-usability", + "title": "Tips for github actions usability", + "lastModifiedAt": 1726839804972, + "htmlId": "articles--4-tips-for-github-ac--c93d8d672b", + "sectionRelativeRepoPath": "4-tips-for-github-actions-usability.md", + "meta": { + "category": "engineering", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-01-03", + "articleTitle": "4 tips for GitHub Actions usability (+2 bonus tips for debugging)", + "articleImageUrl": "/images/articles/4-tips-for-github-actions-usability-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/apple-developer-certificates-on-linux-for-configuration-profile-signing", + "title": "Apple developer certificates on linux for configuration profile signing", + "lastModifiedAt": 1726839804973, + "htmlId": "articles--apple-developer-cert--3d7bfdf01f", + "sectionRelativeRepoPath": "apple-developer-certificates-on-linux-for-configuration-profile-signing.md", + "meta": { + "articleTitle": "Apple developer certificates on Linux for configuration profile signing", + "authorFullName": "Brock Walters", + "authorGitHubUsername": "nonpunctual", + "category": "guides", + "publishedOn": "2024-03-06", + "articleImageUrl": "/images/articles/apple-developer-certificates-on-linux-for-configuration-profile-signing-1600x900@2x.png", + "description": "This guide walks through the process of adding an Apple signing certificate to a Linux host." + } + }, + { + "url": "/announcements/a-new-fleet", + "title": "A new Fleet", + "lastModifiedAt": 1726839804974, + "htmlId": "articles--a-new-fleet--0c5af0e434", + "sectionRelativeRepoPath": "a-new-fleet.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2020-01-05", + "articleTitle": "A new Fleet", + "articleImageUrl": "/images/articles/a-new-fleet-cover-700x340@2x.jpeg" + } + }, + { + "url": "/securing/apply-byod-to-soothe-supply-chain-pain", + "title": "Apply byod to soothe supply chain pain", + "lastModifiedAt": 1726839804976, + "htmlId": "articles--apply-byod-to-soothe--866604b091", + "sectionRelativeRepoPath": "apply-byod-to-soothe-supply-chain-pain.md", + "meta": { + "category": "security", + "authorGitHubUsername": "GuillaumeRoss", + "authorFullName": "Guillaume Ross", + "publishedOn": "2022-02-10", + "articleTitle": "Apply BYOD to soothe supply chain pain", + "articleImageUrl": "/images/articles/apply-byod-to-soothe-supply-chain-pain-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/automations", + "title": "Automations", + "lastModifiedAt": 1726839804976, + "htmlId": "articles--automations--ff5e8024a5", + "sectionRelativeRepoPath": "automations.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-03", + "articleTitle": "Automations", + "description": "Configure Fleet automations to trigger webhooks or create tickets in Jira and Zendesk for vulnerability, policy, and host status events." + } + }, + { + "url": "/guides/building-webhook-flows-with-fleet-and-tines", + "title": "Building webhook flows with Fleet and tines", + "lastModifiedAt": 1726839804978, + "htmlId": "articles--building-webhook-flo--3ffb4a9791", + "sectionRelativeRepoPath": "building-webhook-flows-with-fleet-and-tines.md", + "meta": { + "articleTitle": "Building webhook flows with Fleet and Tines", + "authorFullName": "Victor Lyuboslavsky", + "authorGitHubUsername": "getvictor", + "category": "guides", + "publishedOn": "2024-05-30", + "articleImageUrl": "/images/articles/building-webhook-flows-with-fleet-and-tines-1600x900@2x.png", + "description": "A guide to workflows using Tines and Fleet via webhook to update outdated OS versions." + } + }, + { + "url": "/guides/building-an-effective-dashboard-with-fleet-rest-api-flask-and-plotly", + "title": "Building an effective dashboard with Fleet REST API flask and plotly", + "lastModifiedAt": 1726839804979, + "htmlId": "articles--building-an-effectiv--d3c30b5cf6", + "sectionRelativeRepoPath": "building-an-effective-dashboard-with-fleet-rest-api-flask-and-plotly.md", + "meta": { + "articleTitle": "Building an effective dashboard with Fleet's REST API, Flask, and Plotly: A step-by-step guide", + "authorFullName": "Dave Herder", + "authorGitHubUsername": "dherder", + "category": "guides", + "publishedOn": "2023-05-22", + "articleImageUrl": "/images/articles/building-an-effective-dashboard-with-fleet-rest-api-flask-and-plotly@2x.jpg", + "description": "Step-by-step guide on building a dynamic dashboard with Fleet's REST API, Flask, and Plotly. Master data visualization with open-source tools!" + } + }, + { + "url": "/guides/certificates-in-fleetd", + "title": "Certificates in fleetd", + "lastModifiedAt": 1726839804980, + "htmlId": "articles--certificates-in-flee--f860411dcf", + "sectionRelativeRepoPath": "certificates-in-fleetd.md", + "meta": { + "articleTitle": "Certificates in fleetd", + "authorFullName": "Lucas Manuel Rodriguez", + "authorGitHubUsername": "lucasmrod", + "category": "guides", + "publishedOn": "2024-07-09", + "articleImageUrl": "/images/articles/apple-developer-certificates-on-linux-for-configuration-profile-signing-1600x900@2x.png", + "description": "TLS certificates in fleetd" + } + }, + { + "url": "/guides/chrome-os", + "title": "Chrome os", + "lastModifiedAt": 1726839804981, + "htmlId": "articles--chrome-os--8f9e4f0cca", + "sectionRelativeRepoPath": "chrome-os.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zhumo", + "authorFullName": "Mo Zhu", + "publishedOn": "2023-11-21", + "articleTitle": "ChromeOS", + "description": "Learn about ChromeOS and Fleet." + } + }, + { + "url": "/guides/catch-missed-authorization-checks-during-software-development", + "title": "Catch missed authorization checks during software development", + "lastModifiedAt": 1726839804981, + "htmlId": "articles--catch-missed-authori--74d449dae1", + "sectionRelativeRepoPath": "catch-missed-authorization-checks-during-software-development.md", + "meta": { + "articleTitle": "Catch missed authorization checks during software development", + "authorFullName": "Victor Lyuboslavsky", + "authorGitHubUsername": "getvictor", + "category": "guides", + "publishedOn": "2023-12-04", + "description": "How to perform authorization checks in a golang codebase for cybersecurity" + } + }, + { + "url": "/guides/cis-benchmarks", + "title": "Cis benchmarks", + "lastModifiedAt": 1726839804982, + "htmlId": "articles--cis-benchmarks--c493697884", + "sectionRelativeRepoPath": "cis-benchmarks.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "lucasmrod", + "authorFullName": "Lucas Rodriguez", + "publishedOn": "2024-04-02", + "articleTitle": "CIS Benchmarks", + "description": "Read about how Fleet's implementation of CIS Benchmarks offers consensus-based cybersecurity guidance." + } + }, + { + "url": "/announcements/comparative-look-at-ws1-and-fleet", + "title": "Comparative look at ws1 and Fleet", + "lastModifiedAt": 1726839804983, + "htmlId": "articles--comparative-look-at---d3aff5bdd7", + "sectionRelativeRepoPath": "comparative-look-at-ws1-and-fleet.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-02-01", + "articleTitle": "A comparative look at VMware Workspace ONE and Fleet Device Management", + "articleImageUrl": "/images/articles/comparative-look-at-ws1-and-fleet-1600x900@2x.png" + } + }, + { + "url": "/guides/config-less-fleetd-agent-deployment", + "title": "Config less fleetd agent deployment", + "lastModifiedAt": 1726839804984, + "htmlId": "articles--config-less-fleetd-a--e5546949d5", + "sectionRelativeRepoPath": "config-less-fleetd-agent-deployment.md", + "meta": { + "articleTitle": "Config-less fleetd agent deployment", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "category": "guides", + "publishedOn": "2024-01-31", + "articleImageUrl": "/images/articles/config-less-fleetd-agent-deployment-1600x900@2x.png", + "description": "Config-less `fleetd` agent deployment" + } + }, + { + "url": "/guides/configuring-default-teams-for-devices-in-fleet", + "title": "Configuring default teams for devices in Fleet", + "lastModifiedAt": 1726839804985, + "htmlId": "articles--configuring-default---d9b024f2b7", + "sectionRelativeRepoPath": "configuring-default-teams-for-devices-in-fleet.md", + "meta": { + "articleTitle": "Configuring default teams for macOS, iOS, and iPadOS devices in Fleet", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-09-12", + "description": "This guide will walk you through configuring default teams for devices using the Fleet web UI." + } + }, + { + "url": "/guides/converting-unix-timestamps-with-osquery", + "title": "Converting unix timestamps with osquery", + "lastModifiedAt": 1726839804986, + "htmlId": "articles--converting-unix-time--ace81a16aa", + "sectionRelativeRepoPath": "converting-unix-timestamps-with-osquery.md", + "meta": { + "category": "guides", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-06-15", + "articleTitle": "Converting unix timestamps with osquery", + "articleImageUrl": "/images/articles/converting-unix-timestamps-with-osquery-cover-800x450@2x.jpeg" + } + }, + { + "url": "/guides/correlate-network-connections-with-community-id-in-osquery", + "title": "Correlate network connections with community id in osquery", + "lastModifiedAt": 1726839804987, + "htmlId": "articles--correlate-network-co--10ea0b1641", + "sectionRelativeRepoPath": "correlate-network-connections-with-community-id-in-osquery.md", + "meta": { + "category": "guides", + "authorFullName": "Zach Wasserman", + "authorGitHubUsername": "zwass", + "publishedOn": "2021-06-02", + "articleTitle": "Correlate network connections with community ID in osquery.", + "articleImageUrl": "/images/articles/correlate-network-connections-with-community-id-in-osquery-cover-800x502@2x.jpeg" + } + }, + { + "url": "/guides/custom-os-settings", + "title": "Custom os settings", + "lastModifiedAt": 1726839804988, + "htmlId": "articles--custom-os-settings--5e97a43205", + "sectionRelativeRepoPath": "custom-os-settings.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-27", + "articleTitle": "Custom OS settings", + "description": "Learn how to enforce custom settings on macOS and Window hosts using Fleet's configuration profiles." + } + }, + { + "url": "/announcements/debunk-the-cross-platform-myth", + "title": "Debunk the cross platform myth", + "lastModifiedAt": 1726839804989, + "htmlId": "articles--debunk-the-cross-pla--d46aac3cb4", + "sectionRelativeRepoPath": "debunk-the-cross-platform-myth.md", + "meta": { + "category": "announcements", + "authorFullName": "Mike McNeil", + "authorGitHubUsername": "mikermcneil", + "publishedOn": "2024-08-27", + "articleTitle": "Debunk the cross-platform myth", + "description": "Debunk the cross-platform myth with MDM" + } + }, + { + "url": "/guides/delivering-data-to-snowflake-from-fleet-and-osquery", + "title": "Delivering data to snowflake from Fleet and osquery", + "lastModifiedAt": 1726839804991, + "htmlId": "articles--delivering-data-to-s--9677bbe81b", + "sectionRelativeRepoPath": "delivering-data-to-snowflake-from-fleet-and-osquery.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "t-lark", + "authorFullName": "Tom Larkin", + "publishedOn": "2022-02-01", + "articleTitle": "Delivering data to Snowflake from Fleet and osquery.", + "articleImageUrl": "/images/articles/delivering-data-to-snowflake-from-fleet-and-osquery-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/deploy-fleet-on-aws-ecs", + "title": "Deploy Fleet on aws ecs", + "lastModifiedAt": 1726839804992, + "htmlId": "articles--deploy-fleet-on-aws---ca8c5b2fc4", + "sectionRelativeRepoPath": "deploy-fleet-on-aws-ecs.md", + "meta": { + "articleTitle": "Deploy Fleet on AWS ECS", + "authorGitHubUsername": "edwardsb", + "authorFullName": "Ben Edwards", + "publishedOn": "2021-10-06", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-aws-ecs-800x450@2x.png", + "description": "Information for deploying Fleet on AWS ECS." + } + }, + { + "url": "/guides/deploy-fleet-on-aws-with-terraform", + "title": "Deploy Fleet on aws with terraform", + "lastModifiedAt": 1726839804993, + "htmlId": "articles--deploy-fleet-on-aws---8b2a9168ab", + "sectionRelativeRepoPath": "deploy-fleet-on-aws-with-terraform.md", + "meta": { + "articleTitle": "Deploy Fleet on AWS with Terraform", + "authorGitHubUsername": "edwardsb", + "authorFullName": "Ben Edwards", + "publishedOn": "2021-11-30", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-aws-with-terraform-800x450@2x.png", + "description": "Learn how to deploy Fleet on AWS." + } + }, + { + "url": "/guides/deploy-fleet-on-centos", + "title": "Deploy Fleet on centos", + "lastModifiedAt": 1726839804994, + "htmlId": "articles--deploy-fleet-on-cent--4841e96234", + "sectionRelativeRepoPath": "deploy-fleet-on-centos.md", + "meta": { + "articleTitle": "Deploy Fleet on CentOS", + "authorGitHubUsername": "marpaia", + "authorFullName": "Mike Arpaia", + "publishedOn": "2017-09-22", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-centos-800x450@2x.png", + "description": "A guide to deploy Fleet on CentOS." + } + }, + { + "url": "/guides/deploy-fleet-on-cloudgov", + "title": "Deploy Fleet on cloudgov", + "lastModifiedAt": 1726839804995, + "htmlId": "articles--deploy-fleet-on-clou--ecdaaf656b", + "sectionRelativeRepoPath": "deploy-fleet-on-cloudgov.md", + "meta": { + "articleTitle": "Deploy Fleet on Cloud.gov", + "authorGitHubUsername": "JJediny", + "authorFullName": "John Jediny", + "publishedOn": "2022-09-08", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-cloudgov-800x450@2x.png", + "description": "Information for deploying Fleet on Cloud.gov." + } + }, + { + "url": "/guides/deploy-fleet-on-hetzner-cloud", + "title": "Deploy Fleet on hetzner cloud", + "lastModifiedAt": 1726839804999, + "htmlId": "articles--deploy-fleet-on-hetz--ab40dd3e5f", + "sectionRelativeRepoPath": "deploy-fleet-on-hetzner-cloud.md", + "meta": { + "articleTitle": "Deploy Fleet on Hetzner Cloud", + "authorGitHubUsername": "ksatter", + "authorFullName": "Kathy Satterlee", + "publishedOn": "2022-06-27", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-hetzner-cloud-800x450@2x.png", + "description": "Learn how to deploy Fleet on Hetzner Cloud using cloud-init and Docker." + } + }, + { + "url": "/guides/deploy-fleet-on-kubernetes", + "title": "Deploy Fleet on kubernetes", + "lastModifiedAt": 1726839805000, + "htmlId": "articles--deploy-fleet-on-kube--b62fcc97c7", + "sectionRelativeRepoPath": "deploy-fleet-on-kubernetes.md", + "meta": { + "articleTitle": "Deploy Fleet on Kubernetes", + "authorGitHubUsername": "marpaia", + "authorFullName": "Mike Arpaia", + "publishedOn": "2017-11-18", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-kubernetes-800x450@2x.png", + "description": "Learn how to deploy Fleet on Kubernetes." + } + }, + { + "url": "/guides/deploy-fleet-on-render", + "title": "Deploy Fleet on render", + "lastModifiedAt": 1726839805001, + "htmlId": "articles--deploy-fleet-on-rend--175bce353f", + "sectionRelativeRepoPath": "deploy-fleet-on-render.md", + "meta": { + "articleTitle": "Deploy Fleet on Render", + "authorGitHubUsername": "edwardsb", + "authorFullName": "Ben Edwards", + "publishedOn": "2021-11-21", + "category": "guides", + "articleImageUrl": "/images/articles/deploy-fleet-on-render-800x450@2x.png", + "description": "Learn how to deploy Fleet on Render." + } + }, + { + "url": "/guides/deploy-fleet-on-ubuntu-with-elastic", + "title": "Deploy Fleet on ubuntu with elastic", + "lastModifiedAt": 1726839805004, + "htmlId": "articles--deploy-fleet-on-ubun--db33029e1f", + "sectionRelativeRepoPath": "deploy-fleet-on-ubuntu-with-elastic.md", + "meta": { + "articleTitle": "Deploy Fleet on Ubuntu", + "authorGitHubUsername": "defensivedepth", + "authorFullName": "Josh Brower", + "publishedOn": "2024-06-12", + "category": "guides", + "description": "A guide to deploy Fleet and Elastic on Ubuntu.", + "articleImageUrl": "/images/articles/deploy-fleet-on-ubuntu-with-elastic-1600x900@2x.png" + } + }, + { + "url": "/guides/deploy-security-agents", + "title": "Deploy security agents", + "lastModifiedAt": 1726839805005, + "htmlId": "articles--deploy-security-agen--a3a93c715b", + "sectionRelativeRepoPath": "deploy-security-agents.md", + "meta": { + "articleTitle": "Deploy security agents", + "authorFullName": "Roberto Dip", + "authorGitHubUsername": "roperzh", + "category": "guides", + "publishedOn": "2024-08-05", + "articleImageUrl": "/images/articles/deploy-security-agents-1600x900@2x.png", + "description": "This guide will walk you through adding software to Fleet." + } + }, + { + "url": "/securing/detect-log4j-with-osquery-and-fleet", + "title": "Detect log4j with osquery and Fleet", + "lastModifiedAt": 1726839805006, + "htmlId": "articles--detect-log4j-with-os--812eb5ba15", + "sectionRelativeRepoPath": "detect-log4j-with-osquery-and-fleet.md", + "meta": { + "category": "security", + "authorFullName": "Zach Wasserman", + "authorGitHubUsername": "zwass", + "publishedOn": "2021-12-15", + "articleTitle": "Detect Log4j with osquery (and Fleet)", + "articleImageUrl": "/images/articles/detect-log4j-with-osquery-and-fleet-1600x900@2x.jpg" + } + }, + { + "url": "/guides/discovering-chrome-ai-using-fleet", + "title": "Discovering chrome ai using Fleet", + "lastModifiedAt": 1726839805007, + "htmlId": "articles--discovering-chrome-a--4de87d4fb6", + "sectionRelativeRepoPath": "discovering-chrome-ai-using-fleet.md", + "meta": { + "articleTitle": "Discovering Chrome AI using Fleet", + "authorFullName": "Brock Walters", + "authorGitHubUsername": "nonpunctual", + "category": "guides", + "publishedOn": "2024-09-06", + "articleImageUrl": "/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg", + "description": "Use Fleet to detect and monitor settings enabled in Google Chrome by querying Chrome's preferences JSON file." + } + }, + { + "url": "/guides/discovering-geacon-using-fleet", + "title": "Discovering geacon using Fleet", + "lastModifiedAt": 1726839805008, + "htmlId": "articles--discovering-geacon-u--bab06239aa", + "sectionRelativeRepoPath": "discovering-geacon-using-fleet.md", + "meta": { + "articleTitle": "Discovering Geacon using Fleet", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2023-05-18", + "articleImageUrl": "/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg", + "description": "Enterprise security teams can use Fleet to identify and locate Geacon payloads and protect their macOS devices from this threat." + } + }, + { + "url": "/guides/discovering-xz-vulnerability-with-fleet", + "title": "Discovering xz vulnerability with Fleet", + "lastModifiedAt": 1726839805010, + "htmlId": "articles--discovering-xz-vulne--0a7dc5a7f8", + "sectionRelativeRepoPath": "discovering-xz-vulnerability-with-fleet.md", + "meta": { + "articleTitle": "Discovering xz vulnerability with Fleet", + "authorFullName": "Brock Walters", + "authorGitHubUsername": "nonpunctual", + "category": "guides", + "publishedOn": "2024-06-03", + "articleImageUrl": "/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg", + "description": "Discover and create a comprehensive end-to-end remediation workflow for the xz vulnerability (CVE-2024-3094) with Fleet." + } + }, + { + "url": "/securing/does-osquery-violate-the-new-york-employee-monitoring-law", + "title": "Does osquery violate the new york employee monitoring law", + "lastModifiedAt": 1726839805011, + "htmlId": "articles--does-osquery-violate--fcac4cc8a5", + "sectionRelativeRepoPath": "does-osquery-violate-the-new-york-employee-monitoring-law.md", + "meta": { + "category": "security", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-04-18", + "articleTitle": "Does osquery violate the New York employee monitoring law?" + } + }, + { + "url": "/guides/downgrade-fleet", + "title": "Downgrade Fleet", + "lastModifiedAt": 1726839805012, + "htmlId": "articles--downgrade-fleet--76de2fe679", + "sectionRelativeRepoPath": "downgrade-fleet.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "eashaw", + "authorFullName": "Eric Shaw", + "publishedOn": "2024-01-09", + "articleTitle": "Downgrade from Fleet Premium", + "description": "Learn how to downgrade from Fleet Premium." + } + }, + { + "url": "/guides/driving-company-culture-through-ai-haiku-poetry", + "title": "Driving company culture through ai haiku poetry", + "lastModifiedAt": 1726839805013, + "htmlId": "articles--driving-company-cult--52db9708d4", + "sectionRelativeRepoPath": "driving-company-culture-through-ai-haiku-poetry.md", + "meta": { + "articleTitle": "Driving company culture through AI haiku poetry", + "authorFullName": "Luke Heath", + "authorGitHubUsername": "lukeheath", + "category": "guides", + "publishedOn": "2024-04-17", + "articleImageUrl": "/images/articles/driving-company-culture-through-ai-haiku-poetry-1600x900@2x.png", + "description": "Code and verse entwine, Silicon sparks, haikus shine, Art meets design line." + } + }, + { + "url": "/securing/ebpf-the-future-of-osquery-on-linux", + "title": "Ebpf the future of osquery on linux", + "lastModifiedAt": 1726839805014, + "htmlId": "articles--ebpf-the-future-of-o--cd30e84170", + "sectionRelativeRepoPath": "ebpf-the-future-of-osquery-on-linux.md", + "meta": { + "category": "security", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2021-01-25", + "articleTitle": "eBPF & the future of osquery on Linux", + "articleImageUrl": "/images/articles/ebpf-the-future-of-osquery-on-linux-cover-700x394@2x.png" + } + }, + { + "url": "/announcements/embracing-the-future-declarative-device-management", + "title": "Embracing the future declarative device management", + "lastModifiedAt": 1726839805015, + "htmlId": "articles--embracing-the-future--b3151457e1", + "sectionRelativeRepoPath": "embracing-the-future-declarative-device-management.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-07-06", + "articleTitle": "Embracing the future: Declarative Device Management", + "articleImageUrl": "/images/articles/embracing-the-future-declarative-device-management@2x.png", + "description": "Explore the transformative impact of Declarative Device Management (DDM), Fleet, and osquery for MacAdmins." + } + }, + { + "url": "/securing/end-user-self-remediation", + "title": "End user self remediation", + "lastModifiedAt": 1726839805016, + "htmlId": "articles--end-user-self-remedi--1ebc67c784", + "sectionRelativeRepoPath": "end-user-self-remediation.md", + "meta": { + "category": "security", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-12-15", + "articleTitle": "End-user self remediation: empower your employees to fix security issues with Fleet" + } + }, + { + "url": "/announcements/endpoint-managements-crucial-role-in-healthcare", + "title": "Endpoint managements crucial role in healthcare", + "lastModifiedAt": 1726839805017, + "htmlId": "articles--endpoint-managements--ec90fcd20a", + "sectionRelativeRepoPath": "endpoint-managements-crucial-role-in-healthcare.md", + "meta": { + "category": "announcements", + "authorFullName": "Alex Mitchell", + "authorGitHubUsername": "alexmitchelliii", + "publishedOn": "2024-05-24", + "articleTitle": "Endpoint management's crucial role in healthcare", + "articleImageUrl": "/images/articles/endpoint-managements-crucial-role-in-healthcare-1600x900@2x.png", + "description": "Discover how robust endpoint management is essential for securing healthcare data, ensuring compliance, and building patient trust." + } + }, + { + "url": "/guides/enforce-disk-encryption", + "title": "Enforce disk encryption", + "lastModifiedAt": 1726839805018, + "htmlId": "articles--enforce-disk-encrypt--0ab61200c1", + "sectionRelativeRepoPath": "enforce-disk-encryption.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-14", + "articleTitle": "Enforce disk encryption", + "description": "Learn how to enforce disk encryption on macOS and Windows hosts and manage encryption keys with Fleet Premium." + } + }, + { + "url": "/guides/enforce-os-updates", + "title": "Enforce os updates", + "lastModifiedAt": 1726839805019, + "htmlId": "articles--enforce-os-updates--0ddd6f9117", + "sectionRelativeRepoPath": "enforce-os-updates.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-10", + "articleTitle": "Enforce OS updates", + "description": "Learn how to manage OS updates on macOS, Windows, iOS, and iPadOS devices." + } + }, + { + "url": "/announcements/enhancing-fleets-vulnerability-management-with-vulncheck-integration", + "title": "Enhancing fleets vulnerability management with vulncheck integration", + "lastModifiedAt": 1726839805020, + "htmlId": "articles--enhancing-fleets-vul--3cc4d5cb3a", + "sectionRelativeRepoPath": "enhancing-fleets-vulnerability-management-with-vulncheck-integration.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-04-23", + "articleTitle": "Enhancing Fleet's vulnerability management with VulnCheck integration", + "articleImageUrl": "/images/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration-1600x900@2x.png" + } + }, + { + "url": "/announcements/enhancing-k-12-cybersecurity-with-fcc-funds-and-fleet", + "title": "Enhancing k 12 cybersecurity with fcc funds and Fleet", + "lastModifiedAt": 1726839805021, + "htmlId": "articles--enhancing-k-12-cyber--90c76b24ef", + "sectionRelativeRepoPath": "enhancing-k-12-cybersecurity-with-fcc-funds-and-fleet.md", + "meta": { + "category": "announcements", + "authorFullName": "Alex Mitchell", + "authorGitHubUsername": "alexmitchelliii", + "publishedOn": "2024-07-25", + "articleTitle": "Enhancing K-12 cybersecurity with FCC funds and Fleet", + "articleImageUrl": "/images/articles/enhancing-k-12-cybersecurity-with-fcc-funds-and-fleet-1600x900@2x.png" + } + }, + { + "url": "/guides/enroll-hosts", + "title": "Enroll hosts", + "lastModifiedAt": 1726839805023, + "htmlId": "articles--enroll-hosts--72fecd86ff", + "sectionRelativeRepoPath": "enroll-hosts.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-08", + "articleTitle": "Enroll hosts", + "description": "Learn how to enroll hosts to Fleet." + } + }, + { + "url": "/guides/enrolling-a-digital-ocean-droplet-on-a-fleet-instance", + "title": "Enrolling a digital ocean droplet on a Fleet instance", + "lastModifiedAt": 1726839805025, + "htmlId": "articles--enrolling-a-digital---6fbc5a61b0", + "sectionRelativeRepoPath": "enrolling-a-digital-ocean-droplet-on-a-fleet-instance.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "DominusKelvin", + "authorFullName": "Kelvin Omereshone", + "publishedOn": "2022-05-26", + "articleTitle": "Enrolling a DigitalOcean Droplet on a Fleet instance", + "articleImageUrl": "/images/articles/enrolling-a-digitalocean-droplet-server-on-a-fleet-instance-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-bradley-chambers", + "title": "Expeditioners bradley chambers", + "lastModifiedAt": 1726839805026, + "htmlId": "articles--expeditioners-bradle--434ed8f62f", + "sectionRelativeRepoPath": "expeditioners-bradley-chambers.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-07-20", + "articleTitle": "ExpedITioners podcast with Bradley Chambers", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep1-1600x900@2x.png" + } + }, + { + "url": "/podcasts/expeditioners-charles-edge", + "title": "Expeditioners charles edge", + "lastModifiedAt": 1726839805027, + "htmlId": "articles--expeditioners-charle--078e2e677d", + "sectionRelativeRepoPath": "expeditioners-charles-edge.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-10-23", + "articleTitle": "ExpedITioners podcast with Charles Edge", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep5-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-huxley-barbee", + "title": "Expeditioners huxley barbee", + "lastModifiedAt": 1726839805027, + "htmlId": "articles--expeditioners-huxley--59793f39c1", + "sectionRelativeRepoPath": "expeditioners-huxley-barbee.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2024-01-30", + "articleTitle": "ExpedITioners podcast with Huxley Barbee", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep8-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-jeff-chao", + "title": "Expeditioners jeff chao", + "lastModifiedAt": 1726839805028, + "htmlId": "articles--expeditioners-jeff-c--69f6b2fce1", + "sectionRelativeRepoPath": "expeditioners-jeff-chao.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-11-15", + "articleTitle": "ExpedITioners podcast with Jeff Chao", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep6-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-john-reynolds", + "title": "Expeditioners john reynolds", + "lastModifiedAt": 1726839805029, + "htmlId": "articles--expeditioners-john-r--2abfb47f0e", + "sectionRelativeRepoPath": "expeditioners-john-reynolds.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-09-21", + "articleTitle": "ExpedITioners podcast with John Reynolds", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep4-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-niels-hofmans", + "title": "Expeditioners niels hofmans", + "lastModifiedAt": 1726839805030, + "htmlId": "articles--expeditioners-niels---d1c8e645af", + "sectionRelativeRepoPath": "expeditioners-niels-hofmans.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-08-22", + "articleTitle": "ExpedITioners podcast with Niels Hofmans", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep2-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-podcast-with-marcus-ransom", + "title": "Expeditioners podcast with marcus ransom", + "lastModifiedAt": 1726839805031, + "htmlId": "articles--expeditioners-podcas--98c32a782f", + "sectionRelativeRepoPath": "expeditioners-podcast-with-marcus-ransom.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-12-11", + "articleTitle": "ExpedITioners podcast with Marcus Ransom", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep7-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/expeditioners-rich-trouton", + "title": "Expeditioners rich trouton", + "lastModifiedAt": 1726839805032, + "htmlId": "articles--expeditioners-rich-t--c394f4ba38", + "sectionRelativeRepoPath": "expeditioners-rich-trouton.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2023-08-31", + "articleTitle": "ExpedITioners podcast with Rich Trouton", + "articleImageUrl": "/images/articles/expeditioners-podcast-ep3-1600x900@2x.jpg" + } + }, + { + "url": "/guides/filtering-software-by-vulnerability", + "title": "Filtering software by vulnerability", + "lastModifiedAt": 1726839805033, + "htmlId": "articles--filtering-software-b--900d8b7307", + "sectionRelativeRepoPath": "filtering-software-by-vulnerability.md", + "meta": { + "articleTitle": "Filtering software by vulnerability in Fleet", + "authorFullName": "Tim Lee", + "authorGitHubUsername": "mostlikelee", + "category": "guides", + "publishedOn": "2024-08-30", + "articleImageUrl": "/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg", + "description": "Filter software by vulnerability in Fleet to prioritize critical patches and enhance your organization's security posture." + } + }, + { + "url": "/releases/fleet-3.10.0", + "title": "Fleet 3.10.0", + "lastModifiedAt": 1726839805034, + "htmlId": "articles--fleet-3100--09d2002dcd", + "sectionRelativeRepoPath": "fleet-3.10.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-04-01", + "articleTitle": "Fleet 3.10.0 released with agent auto-updates beta", + "articleImageUrl": "/images/articles/fleet-3.10.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.11.0", + "title": "Fleet 3.11.0", + "lastModifiedAt": 1726839805035, + "htmlId": "articles--fleet-3110--ad56a464f5", + "sectionRelativeRepoPath": "fleet-3.11.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-04-29", + "articleTitle": "Fleet 3.11.0 released with software inventory", + "articleImageUrl": "/images/articles/fleet-3.11.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.12.0", + "title": "Fleet 3.12.0", + "lastModifiedAt": 1726839805036, + "htmlId": "articles--fleet-3120--8f3c795b51", + "sectionRelativeRepoPath": "fleet-3.12.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-05-20", + "articleTitle": "Fleet 3.12.0", + "articleImageUrl": "/images/articles/fleet-3.12.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.5.0", + "title": "Fleet 3.5.0", + "lastModifiedAt": 1726839805037, + "htmlId": "articles--fleet-350--0912885a04", + "sectionRelativeRepoPath": "fleet-3.5.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2020-12-12", + "articleTitle": "Fleet 3.5.0", + "articleImageUrl": "/images/articles/fleet-3.5.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.6.0", + "title": "Fleet 3.6.0", + "lastModifiedAt": 1726839805039, + "htmlId": "articles--fleet-360--b415aaaf59", + "sectionRelativeRepoPath": "fleet-3.6.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-01-09", + "articleTitle": "Fleet 3.6.0", + "articleImageUrl": "/images/articles/fleet-3.6.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.13.0", + "title": "Fleet 3.13.0", + "lastModifiedAt": 1726839805041, + "htmlId": "articles--fleet-3130--6a4b26ee04", + "sectionRelativeRepoPath": "fleet-3.13.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-06-04", + "articleTitle": "Fleet 3.13.0", + "articleImageUrl": "/images/articles/fleet-3.13.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.7.1", + "title": "Fleet 3.7.1", + "lastModifiedAt": 1726839805042, + "htmlId": "articles--fleet-371--a3099c00cb", + "sectionRelativeRepoPath": "fleet-3.7.1.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-02-04", + "articleTitle": "Fleet 3.7.1", + "articleImageUrl": "/images/articles/fleet-3.7.1-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.8.0", + "title": "Fleet 3.8.0", + "lastModifiedAt": 1726839805042, + "htmlId": "articles--fleet-380--681019a9ad", + "sectionRelativeRepoPath": "fleet-3.8.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-02-26", + "articleTitle": "Fleet 3.8.0", + "articleImageUrl": "/images/articles/fleet-3.8.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-3.9.0", + "title": "Fleet 3.9.0", + "lastModifiedAt": 1726839805043, + "htmlId": "articles--fleet-390--7ceb277f2f", + "sectionRelativeRepoPath": "fleet-3.9.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-03-10", + "articleTitle": "Fleet 3.9.0", + "articleImageUrl": "/images/articles/fleet-3.9.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.0.0", + "title": "Fleet 4.0.0", + "lastModifiedAt": 1726839805044, + "htmlId": "articles--fleet-400--33d96e46d6", + "sectionRelativeRepoPath": "fleet-4.0.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-06-30", + "articleTitle": "Fleet 4.0.0 released with Role-based access control and Teams features", + "articleImageUrl": "/images/articles/fleet-4.0.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.1.0", + "title": "Fleet 4.1.0", + "lastModifiedAt": 1726839805045, + "htmlId": "articles--fleet-410--2f2a288a79", + "sectionRelativeRepoPath": "fleet-4.1.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-07-27", + "articleTitle": "Fleet 4.1.0 released with Schedule and Activity feed features", + "articleImageUrl": "/images/articles/fleet-4.1.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.10.0", + "title": "Fleet 4.10.0", + "lastModifiedAt": 1726839805046, + "htmlId": "articles--fleet-4100--dd259b5e42", + "sectionRelativeRepoPath": "fleet-4.10.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2022-02-14", + "articleTitle": "Fleet 4.10.0 brings new features and improvements for vulnerability analysts.", + "articleImageUrl": "/images/articles/fleet-4.10.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.12.0", + "title": "Fleet 4.12.0", + "lastModifiedAt": 1726839805047, + "htmlId": "articles--fleet-4120--150c6e2731", + "sectionRelativeRepoPath": "fleet-4.12.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2022-03-25", + "articleTitle": "Fleet 4.12.0 | Platform-specific policies, and improved query results", + "articleImageUrl": "/images/articles/fleet-4.12.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.13.0", + "title": "Fleet 4.13.0", + "lastModifiedAt": 1726839805047, + "htmlId": "articles--fleet-4130--771b1f08ac", + "sectionRelativeRepoPath": "fleet-4.13.0.md", + "meta": { + "category": "releases", + "authorFullName": "Fleet", + "authorGitHubUsername": "fleetdm", + "publishedOn": "2022-04-19", + "articleTitle": "Fleet 4.13.0 | Security fixes, policy automations for teams, and aggregated macOS versions for MacAdmins.", + "articleImageUrl": "/images/articles/fleet-4.13.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.11.0", + "title": "Fleet 4.11.0", + "lastModifiedAt": 1726839805048, + "htmlId": "articles--fleet-4110--a057b8896f", + "sectionRelativeRepoPath": "fleet-4.11.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2022-03-07", + "articleTitle": "Fleet 4.11.0 brings impact clarity, improvements to vulnerability processing, and performance updates.", + "articleImageUrl": "/images/articles/fleet-4.11.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.14.0", + "title": "Fleet 4.14.0", + "lastModifiedAt": 1726839805049, + "htmlId": "articles--fleet-4140--e58b7a34f3", + "sectionRelativeRepoPath": "fleet-4.14.0.md", + "meta": { + "category": "releases", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-05-06", + "articleTitle": "Fleet 4.14.0 adds beta support for automatic ticket creation and improves the live query experience.", + "articleImageUrl": "/images/articles/fleet-4.14.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.16.0", + "title": "Fleet 4.16.0", + "lastModifiedAt": 1726839805051, + "htmlId": "articles--fleet-4160--ac79cd8c59", + "sectionRelativeRepoPath": "fleet-4.16.0.md", + "meta": { + "category": "releases", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-06-16", + "articleTitle": "Fleet 4.16.0 | more customization, beefed up vuln management, Jira added to integrations.", + "articleImageUrl": "/images/articles/fleet-4.16.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.17.0", + "title": "Fleet 4.17.0", + "lastModifiedAt": 1726839805052, + "htmlId": "articles--fleet-4170--a276e12e2a", + "sectionRelativeRepoPath": "fleet-4.17.0.md", + "meta": { + "category": "releases", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-07-11", + "articleTitle": "Fleet 4.17.0 | Better osquery management, user engagement, improved host vitals.", + "articleImageUrl": "/images/articles/fleet-4.17.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.15.0", + "title": "Fleet 4.15.0", + "lastModifiedAt": 1726839805053, + "htmlId": "articles--fleet-4150--3865641c1c", + "sectionRelativeRepoPath": "fleet-4.15.0.md", + "meta": { + "category": "releases", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-05-30", + "articleTitle": "Fleet 4.15.0 adds beta support for Self-service, Scope transparency, and brings Zendesk to the party.", + "articleImageUrl": "/images/articles/fleet-4.15.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.18.0", + "title": "Fleet 4.18.0", + "lastModifiedAt": 1726839805054, + "htmlId": "articles--fleet-4180--9e4ce6c31b", + "sectionRelativeRepoPath": "fleet-4.18.0.md", + "meta": { + "category": "releases", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-08-03", + "articleTitle": "Fleet 4.18.0 | Better security and user messaging in Fleet Desktop", + "articleImageUrl": "/images/articles/fleet-4.18.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.19.0", + "title": "Fleet 4.19.0", + "lastModifiedAt": 1726839805055, + "htmlId": "articles--fleet-4190--450188c15f", + "sectionRelativeRepoPath": "fleet-4.19.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2022-08-22", + "articleTitle": "Fleet 4.19.0 | Just-in-time (JIT) user provisioning, remaining disk space, aggregate Windows and mobile device management (MDM) data", + "articleImageUrl": "/images/articles/fleet-4.19.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.2.0", + "title": "Fleet 4.2.0", + "lastModifiedAt": 1726839805055, + "htmlId": "articles--fleet-420--ead484f1f9", + "sectionRelativeRepoPath": "fleet-4.2.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2021-08-12", + "articleTitle": "Fleet 4.2.0", + "articleImageUrl": "/images/articles/fleet-4.2.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.20.0", + "title": "Fleet 4.20.0", + "lastModifiedAt": 1726839805057, + "htmlId": "articles--fleet-4200--3a3e9234b6", + "sectionRelativeRepoPath": "fleet-4.20.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2022-09-09", + "articleTitle": "Fleet 4.20.0 | Aggregate Munki issues, test features on canary teams, improved macOS vulnerability detection", + "articleImageUrl": "/images/articles/fleet-4.20.0-1600x900.jpg" + } + }, + { + "url": "/releases/fleet-4.21.0", + "title": "Fleet 4.21.0", + "lastModifiedAt": 1726839805058, + "htmlId": "articles--fleet-4210--ef1f69ba72", + "sectionRelativeRepoPath": "fleet-4.21.0.md", + "meta": { + "category": "releases", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-10-05", + "articleTitle": "Fleet 4.21.0 | Validate config and teams YAML documents, manage osquery flags remotely with Orbit, view team and global policy compliance", + "articleImageUrl": "/images/articles/fleet-4.21.0-1600x900@2x.jpeg" + } + }, + { + "url": "/releases/fleet-4.22.0", + "title": "Fleet 4.22.0", + "lastModifiedAt": 1726839805059, + "htmlId": "articles--fleet-4220--79ccc66c3c", + "sectionRelativeRepoPath": "fleet-4.22.0.md", + "meta": { + "category": "releases", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-10-21", + "articleTitle": "Fleet 4.22.0 | Easier access to host information, better query console UX, and clearer display names", + "articleImageUrl": "/images/articles/fleet-4.22.0-cover-800x450@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.23.0", + "title": "Fleet 4.23.0", + "lastModifiedAt": 1726839805060, + "htmlId": "articles--fleet-4230--653ee52499", + "sectionRelativeRepoPath": "fleet-4.23.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2022-11-14", + "articleTitle": "Fleet 4.23.0 | Better insight into inherited policies, improved host vitals, and more configuration visibility", + "articleImageUrl": "/images/articles/fleet-4.23.0-800x450@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.24.0", + "title": "Fleet 4.24.0", + "lastModifiedAt": 1726839805061, + "htmlId": "articles--fleet-4240--19516bb4b8", + "sectionRelativeRepoPath": "fleet-4.24.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2022-12-06", + "articleTitle": "Fleet 4.24.0 | Live query notifications and navigation improvements", + "articleImageUrl": "/images/articles/fleet-4.24.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.25.0", + "title": "Fleet 4.25.0", + "lastModifiedAt": 1726839805063, + "htmlId": "articles--fleet-4250--9127fac1f2", + "sectionRelativeRepoPath": "fleet-4.25.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2023-01-03", + "articleTitle": "Fleet 4.25.0 | Extra security and MDM visibility", + "articleImageUrl": "/images/articles/fleet-4.25.0-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.26.0", + "title": "Fleet 4.26.0", + "lastModifiedAt": 1726839805064, + "htmlId": "articles--fleet-4260--3ecc26a58f", + "sectionRelativeRepoPath": "fleet-4.26.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2023-01-16", + "articleTitle": "Fleet 4.26.0 | Easier osquery extensions, external audit log destinations, and cleaner data lakes", + "articleImageUrl": "/images/articles/fleet-4.26.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.27.0", + "title": "Fleet 4.27.0", + "lastModifiedAt": 1726839805065, + "htmlId": "articles--fleet-4270--5def591f64", + "sectionRelativeRepoPath": "fleet-4.27.0.md", + "meta": { + "category": "releases", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "publishedOn": "2023-02-14", + "articleTitle": "Fleet 4.27.0 | Improved access management and improved search filters", + "articleImageUrl": "/images/articles/fleet-4.27.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.28.0", + "title": "Fleet 4.28.0", + "lastModifiedAt": 1726839805066, + "htmlId": "articles--fleet-4280--52f2441fa4", + "sectionRelativeRepoPath": "fleet-4.28.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-02-28", + "articleTitle": "Fleet 4.28.0 | CIS benchmarks for Ventura", + "articleImageUrl": "/images/articles/fleet-4.28.0-800x450@2x.png" + } + }, + { + "url": "/releases/fleet-4.29.0", + "title": "Fleet 4.29.0", + "lastModifiedAt": 1726839805067, + "htmlId": "articles--fleet-4290--507fc72ef3", + "sectionRelativeRepoPath": "fleet-4.29.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-03-22", + "articleTitle": "Fleet 4.29.0 | SSO provides JIT Fleet user roles", + "articleImageUrl": "/images/articles/fleet-4.29.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.3.0", + "title": "Fleet 4.3.0", + "lastModifiedAt": 1726839805068, + "htmlId": "articles--fleet-430--f231d44352", + "sectionRelativeRepoPath": "fleet-4.3.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-09-07", + "articleTitle": "Fleet 4.3.0", + "articleImageUrl": "/images/articles/fleet-4.3.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.30.0", + "title": "Fleet 4.30.0", + "lastModifiedAt": 1726839805069, + "htmlId": "articles--fleet-4300--0e053dac25", + "sectionRelativeRepoPath": "fleet-4.30.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-04-11", + "articleTitle": "Fleet 4.30.0 | MDM public beta, Observer+ role, Vulnerability publication dates", + "articleImageUrl": "/images/articles/fleet-4.30.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.31.0", + "title": "Fleet 4.31.0", + "lastModifiedAt": 1726839805071, + "htmlId": "articles--fleet-4310--439ea795b4", + "sectionRelativeRepoPath": "fleet-4.31.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-05-01", + "articleTitle": "Fleet 4.31.0 | MDM enrollment workflow, API user role.", + "articleImageUrl": "/images/articles/fleet-4.31.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.32.0", + "title": "Fleet 4.32.0", + "lastModifiedAt": 1726839805073, + "htmlId": "articles--fleet-4320--221d90689c", + "sectionRelativeRepoPath": "fleet-4.32.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-05-24", + "articleTitle": "Fleet 4.32.0 | User migration, customizing macOS Setup Assistant.", + "articleImageUrl": "/images/articles/fleet-4.32.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.33.0", + "title": "Fleet 4.33.0", + "lastModifiedAt": 1726839805074, + "htmlId": "articles--fleet-4330--3b965c130a", + "sectionRelativeRepoPath": "fleet-4.33.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-06-13", + "articleTitle": "Fleet 4.33.0 | ChromeOS support, new verified status", + "articleImageUrl": "/images/articles/fleet-4.33.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.34.0", + "title": "Fleet 4.34.0", + "lastModifiedAt": 1726839805075, + "htmlId": "articles--fleet-4340--aab74d16d2", + "sectionRelativeRepoPath": "fleet-4.34.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-07-12", + "articleTitle": "Fleet 4.34.0 | ChromeOS tables, CIS Benchmark load testing", + "articleImageUrl": "/images/articles/fleet-4.34.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.35.0", + "title": "Fleet 4.35.0", + "lastModifiedAt": 1726839805076, + "htmlId": "articles--fleet-4350--d4921e1140", + "sectionRelativeRepoPath": "fleet-4.35.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-08-01", + "articleTitle": "Fleet 4.35.0 | Improvements and bug fixes.", + "articleImageUrl": "/images/articles/fleet-4.35.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.37.0", + "title": "Fleet 4.37.0", + "lastModifiedAt": 1726839805077, + "htmlId": "articles--fleet-4370--56524e6b70", + "sectionRelativeRepoPath": "fleet-4.37.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-09-07", + "articleTitle": "Fleet 4.37.0 | Remote script execution & Puppet support.", + "articleImageUrl": "/images/articles/fleet-4.37.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.36.0", + "title": "Fleet 4.36.0", + "lastModifiedAt": 1726839805078, + "htmlId": "articles--fleet-4360--0167b9704b", + "sectionRelativeRepoPath": "fleet-4.36.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-08-18", + "articleTitle": "Fleet 4.36.0 | Saved and scheduled queries merge.", + "articleImageUrl": "/images/articles/fleet-4.36.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.38.0", + "title": "Fleet 4.38.0", + "lastModifiedAt": 1726839805080, + "htmlId": "articles--fleet-4380--8522df1a2e", + "sectionRelativeRepoPath": "fleet-4.38.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-09-25", + "articleTitle": "Fleet 4.38.0 | Profile redelivery, NVD details, and custom extension label support.", + "articleImageUrl": "/images/articles/fleet-4.38.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.39.0", + "title": "Fleet 4.39.0", + "lastModifiedAt": 1726839805081, + "htmlId": "articles--fleet-4390--ad9a535d1c", + "sectionRelativeRepoPath": "fleet-4.39.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-10-26", + "articleTitle": "Fleet 4.39.0 | Sonoma support, script library, query reports.", + "articleImageUrl": "/images/articles/fleet-4.39.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.4.0", + "title": "Fleet 4.4.0", + "lastModifiedAt": 1726839805082, + "htmlId": "articles--fleet-440--24061a1eff", + "sectionRelativeRepoPath": "fleet-4.4.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-10-07", + "articleTitle": "Fleet 4.4.0 releases aggregated software inventory, team policies, and improved team scheduling", + "articleImageUrl": "/images/articles/fleet-4.4.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.40.0", + "title": "Fleet 4.40.0", + "lastModifiedAt": 1726839805083, + "htmlId": "articles--fleet-4400--53f1a0954b", + "sectionRelativeRepoPath": "fleet-4.40.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-11-06", + "articleTitle": "Fleet 4.40.0 | More Data, Rapid Security Response, CIS Benchmark updates.", + "articleImageUrl": "/images/articles/fleet-4.40.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.41.0", + "title": "Fleet 4.41.0", + "lastModifiedAt": 1726839805084, + "htmlId": "articles--fleet-4410--f4c37d963b", + "sectionRelativeRepoPath": "fleet-4.41.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-11-28", + "articleTitle": "Fleet 4.41.0 | NVD API 2.0, Windows script library.", + "articleImageUrl": "/images/articles/fleet-4.41.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.42.0", + "title": "Fleet 4.42.0", + "lastModifiedAt": 1726839805086, + "htmlId": "articles--fleet-4420--8d6641fa28", + "sectionRelativeRepoPath": "fleet-4.42.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-12-21", + "articleTitle": "Fleet 4.42.0 | Query performance reporting, host targeting improvements.", + "articleImageUrl": "/images/articles/fleet-4.42.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.43.0", + "title": "Fleet 4.43.0", + "lastModifiedAt": 1726839805087, + "htmlId": "articles--fleet-4430--296526b139", + "sectionRelativeRepoPath": "fleet-4.43.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-01-09", + "articleTitle": "Fleet 4.43.0 | Query performance reporting, host targeting improvements.", + "articleImageUrl": "/images/articles/fleet-4.43.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.44.0", + "title": "Fleet 4.44.0", + "lastModifiedAt": 1726839805089, + "htmlId": "articles--fleet-4440--e0c9504248", + "sectionRelativeRepoPath": "fleet-4.44.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-02-05", + "articleTitle": "Fleet 4.44.0 | Script execution, host expiry, and host targeting improvements.", + "articleImageUrl": "/images/articles/fleet-4.44.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.45.0", + "title": "Fleet 4.45.0", + "lastModifiedAt": 1726839805090, + "htmlId": "articles--fleet-4450--525bed4841", + "sectionRelativeRepoPath": "fleet-4.45.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-02-21", + "articleTitle": "Fleet 4.45.0 | Remote lock, Linux script library, osquery storage location.", + "articleImageUrl": "/images/articles/fleet-4.45.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.46.0", + "title": "Fleet 4.46.0", + "lastModifiedAt": 1726839805091, + "htmlId": "articles--fleet-4460--2bc79fbeb9", + "sectionRelativeRepoPath": "fleet-4.46.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-02-26", + "articleTitle": "Fleet 4.46.0 | Automatic SCEP certificate renewal.", + "articleImageUrl": "/images/articles/fleet-4.46.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.47.0", + "title": "Fleet 4.47.0", + "lastModifiedAt": 1726839805092, + "htmlId": "articles--fleet-4470--d61e2e7199", + "sectionRelativeRepoPath": "fleet-4.47.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-03-12", + "articleTitle": "Fleet 4.47.0 | Cross-platform remote wipe, vulnerabilities page, and scripting improvements.", + "articleImageUrl": "/images/articles/fleet-4.47.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.48.0", + "title": "Fleet 4.48.0", + "lastModifiedAt": 1726839805094, + "htmlId": "articles--fleet-4480--ecbe7beab5", + "sectionRelativeRepoPath": "fleet-4.48.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-04-03", + "articleTitle": "Fleet 4.48.0 | IdP local account creation, VS Code extensions.", + "articleImageUrl": "/images/articles/fleet-4.48.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.49.0", + "title": "Fleet 4.49.0", + "lastModifiedAt": 1726839805095, + "htmlId": "articles--fleet-4490--c90f5fc656", + "sectionRelativeRepoPath": "fleet-4.49.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-04-23", + "articleTitle": "Fleet 4.49.0 | VulnCheck's NVD++, device health API, fleetd data parsing.", + "articleImageUrl": "/images/articles/fleet-4.49.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.5.0", + "title": "Fleet 4.5.0", + "lastModifiedAt": 1726839805096, + "htmlId": "articles--fleet-450--2c474c8040", + "sectionRelativeRepoPath": "fleet-4.5.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-11-02", + "articleTitle": "Fleet 4.5.0 with new team admin role, live OS compatibility checking, query performance impact, and a new-look dashboard", + "articleImageUrl": "/images/articles/fleet-4.5.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.50.0", + "title": "Fleet 4.50.0", + "lastModifiedAt": 1726839805098, + "htmlId": "articles--fleet-4500--44757c8700", + "sectionRelativeRepoPath": "fleet-4.50.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-05-22", + "articleTitle": "Fleet 4.50.0 | Security agent deployment, AI descriptions, and Mac Admins SOFA support.", + "articleImageUrl": "/images/articles/fleet-4.50.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.53.0", + "title": "Fleet 4.53.0", + "lastModifiedAt": 1726839805100, + "htmlId": "articles--fleet-4530--1cc540fb24", + "sectionRelativeRepoPath": "fleet-4.53.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-06-25", + "articleTitle": "Fleet 4.53.0 | Better vuln matching, multi-issue hosts, & `fleetd` logs as tables", + "articleImageUrl": "/images/articles/fleet-4.53.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.54.0", + "title": "Fleet 4.54.0", + "lastModifiedAt": 1726839805101, + "htmlId": "articles--fleet-4540--11b1c848f2", + "sectionRelativeRepoPath": "fleet-4.54.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-07-17", + "articleTitle": "Fleet 4.54.0 | Target hosts via label exclusion, script execution time.", + "articleImageUrl": "/images/articles/fleet-4.54.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.51.0", + "title": "Fleet 4.51.0", + "lastModifiedAt": 1726839805102, + "htmlId": "articles--fleet-4510--7274f6fa9d", + "sectionRelativeRepoPath": "fleet-4.51.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-06-10", + "articleTitle": "Fleet 4.51.0 | Global activity webhook, macOS TCC table, and software self-service.", + "articleImageUrl": "/images/articles/fleet-4.51.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.55.0", + "title": "Fleet 4.55.0", + "lastModifiedAt": 1726839805106, + "htmlId": "articles--fleet-4550--f7134a8007", + "sectionRelativeRepoPath": "fleet-4.55.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-08-09", + "articleTitle": "Fleet 4.55.0 | MySQL 8, arm64 support, FileVault improvements, VPP support.", + "articleImageUrl": "/images/articles/fleet-4.55.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.56.0", + "title": "Fleet 4.56.0", + "lastModifiedAt": 1726839805108, + "htmlId": "articles--fleet-4560--6f2f9c6451", + "sectionRelativeRepoPath": "fleet-4.56.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-09-07", + "articleTitle": "Fleet 4.56.0 | Enhanced MDM migration, Exact CVE Search, and Self-Service VPP Apps.", + "articleImageUrl": "/images/articles/fleet-4.56.0-1600x900@2x.png" + } + }, + { + "url": "/releases/fleet-4.6.0", + "title": "Fleet 4.6.0", + "lastModifiedAt": 1726839805109, + "htmlId": "articles--fleet-460--d71c3386e5", + "sectionRelativeRepoPath": "fleet-4.6.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-11-19", + "articleTitle": "Fleet 4.6.0 with osquery installer, enroll secret management, and improved host vitals", + "articleImageUrl": "/images/articles/fleet-4.6.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.7.0", + "title": "Fleet 4.7.0", + "lastModifiedAt": 1726839805110, + "htmlId": "articles--fleet-470--f6d85e866c", + "sectionRelativeRepoPath": "fleet-4.7.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-12-14", + "articleTitle": "Does Fleet 4.7.0 bring more power to your osquery compliance policies? Yes.", + "articleImageUrl": "/images/articles/fleet-4.7.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.8.0", + "title": "Fleet 4.8.0", + "lastModifiedAt": 1726839805111, + "htmlId": "articles--fleet-480--e0296e324b", + "sectionRelativeRepoPath": "fleet-4.8.0.md", + "meta": { + "category": "releases", + "authorFullName": "Drew Baker", + "authorGitHubUsername": "Drew-P-drawers", + "publishedOn": "2021-12-31", + "articleTitle": "Looking for policy automations, Google Chrome profile search, and Munki details from your hosts? Upgrade to Fleet 4.8.0", + "articleImageUrl": "/images/articles/fleet-4.8.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/releases/fleet-4.9.0", + "title": "Fleet 4.9.0", + "lastModifiedAt": 1726839805112, + "htmlId": "articles--fleet-490--d6149315ff", + "sectionRelativeRepoPath": "fleet-4.9.0.md", + "meta": { + "category": "releases", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2022-01-24", + "articleTitle": "Fleet 4.9.0 brings performance updates, paginated live query results, and policy YAML doc support.", + "articleImageUrl": "/images/articles/fleet-4.9.0-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/fleet-ai-assisted-policy-descriptions-and-resolutions", + "title": "Fleet ai assisted policy descriptions and resolutions", + "lastModifiedAt": 1726839805113, + "htmlId": "articles--fleet-ai-assisted-po--74a94535fe", + "sectionRelativeRepoPath": "fleet-ai-assisted-policy-descriptions-and-resolutions.md", + "meta": { + "articleTitle": "Fleet’s AI-assisted policy descriptions and resolutions", + "authorFullName": "Rachel Perkins", + "authorGitHubUsername": "rachelelysia", + "category": "guides", + "publishedOn": "2024-05-20", + "articleImageUrl": "/images/articles/fleet-ai-assisted-policy-descriptions-and-resolutions-1600x900@2x.png", + "description": "AI guides our way, Policies clear, secure paths, Compliance shines bright." + } + }, + { + "url": "/announcements/fleet-adds-support-for-chrome-os", + "title": "Fleet adds support for chrome os", + "lastModifiedAt": 1726839805114, + "htmlId": "articles--fleet-adds-support-f--e846968e31", + "sectionRelativeRepoPath": "fleet-adds-support-for-chrome-os.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-06-13", + "articleTitle": "Fleet enhances device management with ChromeOS support", + "articleImageUrl": "/images/articles/fleet-adds-support-for-chrome-os-1600x900@2x.png", + "description": "We're thrilled to announce that Fleet has expanded support to include ChromeOS and ChromeOS Flex!" + } + }, + { + "url": "/announcements/fleet-desktop-says-hello-world", + "title": "Fleet desktop says hello world", + "lastModifiedAt": 1726839805115, + "htmlId": "articles--fleet-desktop-says-h--b773918322", + "sectionRelativeRepoPath": "fleet-desktop-says-hello-world.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "zhumo", + "authorFullName": "Mo Zhu", + "publishedOn": "2022-08-02", + "articleTitle": "Fleet Desktop says “Hello, world!”", + "articleImageUrl": "/images/articles/fleet-desktop-says-hello-world-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/fleet-desktop", + "title": "Fleet desktop", + "lastModifiedAt": 1726839805116, + "htmlId": "articles--fleet-desktop--9214a6a67a", + "sectionRelativeRepoPath": "fleet-desktop.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zhumo", + "authorFullName": "Mo Zhu", + "publishedOn": "2024-04-19", + "articleTitle": "Fleet Desktop", + "description": "Learn about Fleet Desktop's features for self-remediation and transparency." + } + }, + { + "url": "/announcements/fleet-in-vegas-2023", + "title": "Fleet in vegas 2023", + "lastModifiedAt": 1726839805117, + "htmlId": "articles--fleet-in-vegas-2023--284818a7ab", + "sectionRelativeRepoPath": "fleet-in-vegas-2023.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-08-02", + "articleTitle": "Fleet takes on Vegas: Exploring cybersecurity's future at Black Hat, B-Sides, and DEF CON 31", + "articleImageUrl": "/images/articles/fleet-in-vegas-2023@2x.jpg", + "description": "Explore cybersecurity's cutting edge with Fleet at three top-tier conferences - Black Hat, Security B-Sides, and DEF CON." + } + }, + { + "url": "/releases/fleet-introduces-mdm", + "title": "Fleet introduces mdm", + "lastModifiedAt": 1726839805118, + "htmlId": "articles--fleet-introduces-mdm--e7ec825f3a", + "sectionRelativeRepoPath": "fleet-introduces-mdm.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-04-11", + "articleTitle": "Fleet introduces MDM", + "articleImageUrl": "/images/articles/fleet-mdm-launch-cover-800x450@2x.jpg" + } + }, + { + "url": "/announcements/fleet-in-your-calendar-introducing-maintenance-windows", + "title": "Fleet in your calendar introducing maintenance windows", + "lastModifiedAt": 1726839805119, + "htmlId": "articles--fleet-in-your-calend--35d205d395", + "sectionRelativeRepoPath": "fleet-in-your-calendar-introducing-maintenance-windows.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-04-30", + "articleTitle": "Fleet in your calendar: introducing maintenance windows", + "articleImageUrl": "/images/articles/fleet-in-your-calendar-introducing-maintenance-windows-cover-900x450@2x.png", + "description": "Like any good colleague, when Fleet needs some of your time, it puts it on your calendar." + } + }, + { + "url": "/announcements/fleet-introduces-windows-mdm", + "title": "Fleet introduces windows mdm", + "lastModifiedAt": 1726839805120, + "htmlId": "articles--fleet-introduces-win--c7cafc9ba6", + "sectionRelativeRepoPath": "fleet-introduces-windows-mdm.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-01-24", + "articleTitle": "Fleet introduces Windows MDM", + "articleImageUrl": "/images/articles/fleet-win-mdm-launch-cover-800x450@2x.png" + } + }, + { + "url": "/announcements/fleet-is-abuzz-for-macdevops-yvr-2023", + "title": "Fleet is abuzz for macdevops yvr 2023", + "lastModifiedAt": 1726839805121, + "htmlId": "articles--fleet-is-abuzz-for-m--ad5da5f6fb", + "sectionRelativeRepoPath": "fleet-is-abuzz-for-macdevops-yvr-2023.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-06-07", + "articleTitle": "Fleet is abuzz 🐝 for MacDevOps:YVR", + "articleImageUrl": "/images/articles/fleet-is-abuzz-for-macdevops-yvr-2023@2x.png", + "description": "Fleet is a proud sponsor of MacDevOps:YVR which is back in person in Vancouver, B.C. June 21-22, 2023" + } + }, + { + "url": "/securing/fleet-osquery-unlocking-the-value-of-axonius-with-open-source-telemetry", + "title": "Fleet osquery unlocking the value of axonius with open source telemetry", + "lastModifiedAt": 1726839805122, + "htmlId": "articles--fleet-osquery-unlock--3d8a42de76", + "sectionRelativeRepoPath": "fleet-osquery-unlocking-the-value-of-axonius-with-open-source-telemetry.md", + "meta": { + "category": "security", + "authorFullName": "Brad Macdowall", + "authorGitHubUsername": "BradMacd", + "publishedOn": "2023-12-28", + "articleTitle": "Fleet & osquery: Unlocking the value of Axonius with open-source telemetry", + "articleImageUrl": "/images/articles/fleet-osquery-unlocking-the-value-of-axonius-with-open-source-telemetry-1600x900@2x.png" + } + }, + { + "url": "/guides/fleet-quick-tips-querying-procdump-eula-has-been-accepted", + "title": "Fleet quick tips querying procdump eula has been accepted", + "lastModifiedAt": 1726839805123, + "htmlId": "articles--fleet-quick-tips-que--083c7ab95c", + "sectionRelativeRepoPath": "fleet-quick-tips-querying-procdump-eula-has-been-accepted.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2021-05-11", + "articleTitle": "Fleet quick tips — identify systems where the ProcDump EULA has been accepted", + "articleImageUrl": "/images/articles/fleet-quick-tips-querying-procdump-eula-has-been-accepted-cover-700x440@2x.png" + } + }, + { + "url": "/guides/fleet-terraform-byo-vpc-module", + "title": "Fleet terraform byo vpc module", + "lastModifiedAt": 1726839805124, + "htmlId": "articles--fleet-terraform-byo---dc914e6434", + "sectionRelativeRepoPath": "fleet-terraform-byo-vpc-module.md", + "meta": { + "category": "guides", + "authorFullName": "Robert Fairburn", + "authorGitHubUsername": "rfairburn", + "publishedOn": "2023-09-01", + "articleTitle": "Using the Fleet Terraform module with an existing VPC" + } + }, + { + "url": "/announcements/fleet-terraform-module", + "title": "Fleet terraform module", + "lastModifiedAt": 1726839805125, + "htmlId": "articles--fleet-terraform-modu--290ad35faf", + "sectionRelativeRepoPath": "fleet-terraform-module.md", + "meta": { + "category": "announcements", + "authorFullName": "Zachary Winnerman", + "authorGitHubUsername": "zwinnerman-fleetdm", + "publishedOn": "2023-01-09", + "articleTitle": "Keep Fleet running smoothly on AWS with the new Terraform module" + } + }, + { + "url": "/guides/fleet-usage-statistics", + "title": "Fleet usage statistics", + "lastModifiedAt": 1726839805126, + "htmlId": "articles--fleet-usage-statisti--8212e2baf7", + "sectionRelativeRepoPath": "fleet-usage-statistics.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-13", + "articleTitle": "Fleet usage statistics", + "description": "Learn about Fleet's usage statistics and what information is collected." + } + }, + { + "url": "/success-stories/fleet-user-stories-f100", + "title": "Fleet user stories f100", + "lastModifiedAt": 1726839805127, + "htmlId": "articles--fleet-user-stories-f--869652e2be", + "sectionRelativeRepoPath": "fleet-user-stories-f100.md", + "meta": { + "category": "success stories", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2021-09-29", + "articleTitle": "Fleet user stories — F100", + "articleImageUrl": "/images/articles/fleet-user-stories-f100-cover-800x450@2x.png" + } + }, + { + "url": "/success-stories/fleet-user-stories-schrodinger", + "title": "Fleet user stories schrodinger", + "lastModifiedAt": 1726839805127, + "htmlId": "articles--fleet-user-stories-s--1486ea1812", + "sectionRelativeRepoPath": "fleet-user-stories-schrodinger.md", + "meta": { + "category": "success stories", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2021-09-10", + "articleTitle": "Fleet user stories — Schrödinger", + "articleImageUrl": "/images/articles/fleet-user-stories-schrodinger-cover-800x450@2x.png" + } + }, + { + "url": "/success-stories/fleet-user-stories-wayfair", + "title": "Fleet user stories wayfair", + "lastModifiedAt": 1726839805128, + "htmlId": "articles--fleet-user-stories-w--c78d4fa6b9", + "sectionRelativeRepoPath": "fleet-user-stories-wayfair.md", + "meta": { + "category": "success stories", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2021-08-20", + "articleTitle": "Fleet user stories — Wayfair", + "articleImageUrl": "/images/articles/fleet-user-stories-wayfair-cover-800x450@2x.png" + } + }, + { + "url": "/guides/fleetctl", + "title": "Fleetctl", + "lastModifiedAt": 1726839805129, + "htmlId": "articles--fleetctl--0cb8193ba2", + "sectionRelativeRepoPath": "fleetctl.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-04", + "articleTitle": "fleetctl", + "description": "Read about fleetctl, a CLI tool for managing Fleet and osquery configurations, running queries, generating Fleet's agent (fleetd) and more." + } + }, + { + "url": "/announcements/from-osquery-to-fleet-planting-the-seed", + "title": "From osquery to Fleet planting the seed", + "lastModifiedAt": 1726839805130, + "htmlId": "articles--from-osquery-to-flee--229a9b9742", + "sectionRelativeRepoPath": "from-osquery-to-fleet-planting-the-seed.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-01-20", + "articleTitle": "The next step for Fleet: our $5M seed round 🌱", + "articleImageUrl": "/images/articles/from-osquery-to-fleet-planting-the-seed-cover-800x450@2x.png" + } + }, + { + "url": "/guides/fleetd-updates", + "title": "Fleetd updates", + "lastModifiedAt": 1726839805131, + "htmlId": "articles--fleetd-updates--6d4aebafec", + "sectionRelativeRepoPath": "fleetd-updates.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-04-30", + "articleTitle": "Fleetd updates", + "description": "Information on how to manage and secure Fleet agent updates." + } + }, + { + "url": "/guides/generate-process-trees-with-osquery", + "title": "Generate process trees with osquery", + "lastModifiedAt": 1726839805132, + "htmlId": "articles--generate-process-tre--d1b0edcce1", + "sectionRelativeRepoPath": "generate-process-trees-with-osquery.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2020-03-17", + "articleTitle": "Generate process trees with osquery", + "articleImageUrl": "/images/articles/generate-process-trees-with-osquery-cover-700x393@2x.jpeg" + } + }, + { + "url": "/securing/get-and-stay-compliant-across-your-devices-with-fleet", + "title": "Get and stay compliant across your devices with Fleet", + "lastModifiedAt": 1726839805133, + "htmlId": "articles--get-and-stay-complia--2cb805730d", + "sectionRelativeRepoPath": "get-and-stay-compliant-across-your-devices-with-fleet.md", + "meta": { + "category": "security", + "authorFullName": "Drew Baker", + "authorGitHubUsername": "Drew-P-drawers", + "publishedOn": "2022-03-09", + "articleTitle": "Get and stay compliant across your devices with Fleet.", + "articleImageUrl": "/images/articles/get-and-stay-compliant-across-your-devices-with-fleet-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/get-current-telemetry-from-your-devices-with-live-queries", + "title": "Get current telemetry from your devices with live queries", + "lastModifiedAt": 1726839805134, + "htmlId": "articles--get-current-telemetr--019d64996a", + "sectionRelativeRepoPath": "get-current-telemetry-from-your-devices-with-live-queries.md", + "meta": { + "articleTitle": "Get current telemetry from your devices with live queries", + "authorFullName": "Victor Lyuboslavsky", + "authorGitHubUsername": "getvictor", + "category": "guides", + "publishedOn": "2023-12-27", + "description": "Learn how live queries work under the hood." + } + }, + { + "url": "/announcements/government-agencies-need-to-dith-the-mdm-thicket", + "title": "Government agencies need to dith the mdm thicket", + "lastModifiedAt": 1726839805135, + "htmlId": "articles--government-agencies---f0385b3f79", + "sectionRelativeRepoPath": "government-agencies-need-to-dith-the-mdm-thicket.md", + "meta": { + "category": "announcements", + "authorFullName": "Keith Barnes", + "authorGitHubUsername": "KAB703", + "publishedOn": "2024-02-09", + "articleTitle": "Government agencies need to ditch the MDM thicket: multiple solutions cost you more than you think", + "articleImageUrl": "/images/articles/government-agencies-need-to-dith-the-mdm-thicket-1600x900@2x.png" + } + }, + { + "url": "/announcements/happy-1st-anniversary-fleet", + "title": "Happy 1st anniversary Fleet", + "lastModifiedAt": 1726839805135, + "htmlId": "articles--happy-1st-anniversar--128480e14b", + "sectionRelativeRepoPath": "happy-1st-anniversary-fleet.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2021-10-08", + "articleTitle": "Happy 1st anniversary, Fleet.", + "articleImageUrl": "/images/articles/happy-1st-anniversary-fleet-cover-800x450@2x.png" + } + }, + { + "url": "/securing/how-fleet-helps-federal-agencies-meet-cisa-bod-23-01", + "title": "How Fleet helps federal agencies meet cisa bod 23 01", + "lastModifiedAt": 1726839805136, + "htmlId": "articles--how-fleet-helps-fede--82d74da10e", + "sectionRelativeRepoPath": "how-fleet-helps-federal-agencies-meet-cisa-bod-23-01.md", + "meta": { + "category": "security", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-10-28", + "articleTitle": "How Fleet helps federal agencies meet CISA BOD 23-01", + "articleImageUrl": "/images/articles/BOD-23-01-800x450@2x.jpg" + } + }, + { + "url": "/securing/how-osquery-can-help-cyber-responders", + "title": "How osquery can help cyber responders", + "lastModifiedAt": 1726839805138, + "htmlId": "articles--how-osquery-can-help--eca2df006d", + "sectionRelativeRepoPath": "how-osquery-can-help-cyber-responders.md", + "meta": { + "category": "security", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-11-02", + "articleTitle": "How osquery can help cyber responders.", + "articleImageUrl": "/images/articles/osquery-for-cyber-responders-1600x900@2x.png" + } + }, + { + "url": "/guides/how-to-configure-logging-destinations", + "title": "How to configure logging destinations", + "lastModifiedAt": 1726839805139, + "htmlId": "articles--how-to-configure-log--e7ef58a2dc", + "sectionRelativeRepoPath": "how-to-configure-logging-destinations.md", + "meta": { + "category": "guides", + "authorFullName": "Grant Bilstad", + "authorGitHubUsername": "pacamaster", + "publishedOn": "2024-06-28", + "articleTitle": "How to configure logging destinations", + "articleImageUrl": "/images/articles/how-to-configure-logging-destinations-1600x900@2x.jpg" + } + }, + { + "url": "/guides/how-to-install-osquery-and-enroll-linux-devices-into-fleet", + "title": "How to install osquery and enroll linux devices into Fleet", + "lastModifiedAt": 1726839805140, + "htmlId": "articles--how-to-install-osque--7ef1932c39", + "sectionRelativeRepoPath": "how-to-install-osquery-and-enroll-linux-devices-into-fleet.md", + "meta": { + "category": "guides", + "authorFullName": "Kathy Satterlee", + "authorGitHubUsername": "ksatter", + "publishedOn": "2022-03-19", + "articleTitle": "How to install osquery and enroll Linux devices into Fleet", + "articleImageUrl": "/images/articles/install-osquery-and-enroll-linux-devices-into-fleet-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/how-to-install-osquery-and-enroll-macos-devices-into-fleet", + "title": "How to install osquery and enroll macos devices into Fleet", + "lastModifiedAt": 1726839805142, + "htmlId": "articles--how-to-install-osque--9584297736", + "sectionRelativeRepoPath": "how-to-install-osquery-and-enroll-macos-devices-into-fleet.md", + "meta": { + "category": "guides", + "authorFullName": "Kelvin Omereshone", + "authorGitHubUsername": "dominuskelvin", + "publishedOn": "2022-01-13", + "articleTitle": "How to install osquery and enroll macOS devices into Fleet", + "articleImageUrl": "/images/articles/install-osquery-and-enroll-macos-devices-into-fleet-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/how-to-install-osquery-and-enroll-windows-devices-into-fleet", + "title": "How to install osquery and enroll windows devices into Fleet", + "lastModifiedAt": 1726839805143, + "htmlId": "articles--how-to-install-osque--65750e792f", + "sectionRelativeRepoPath": "how-to-install-osquery-and-enroll-windows-devices-into-fleet.md", + "meta": { + "category": "guides", + "authorFullName": "Kelvin Omereshone", + "authorGitHubUsername": "dominuskelvin", + "publishedOn": "2022-02-03", + "articleTitle": "How to install osquery and enroll Windows devices into Fleet", + "articleImageUrl": "/images/articles/install-osquery-and-enroll-windows-devices-into-fleet-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/how-to-uninstall-osquery", + "title": "How to uninstall osquery", + "lastModifiedAt": 1726839805143, + "htmlId": "articles--how-to-uninstall-osq--7455ca45fc", + "sectionRelativeRepoPath": "how-to-uninstall-osquery.md", + "meta": { + "category": "guides", + "authorFullName": "Eric Shaw", + "authorGitHubUsername": "eashaw", + "publishedOn": "2021-09-08", + "articleTitle": "How to uninstall osquery", + "articleImageUrl": "/images/articles/how-to-uninstall-osquery-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/import-and-export-queries-in-fleet", + "title": "Import and export queries in Fleet", + "lastModifiedAt": 1726839805144, + "htmlId": "articles--import-and-export-qu--44b09ee020", + "sectionRelativeRepoPath": "import-and-export-queries-in-fleet.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2021-02-16", + "articleTitle": "Import and export queries in Fleet", + "articleImageUrl": "/images/articles/import-and-export-queries-in-Fleet-1600x900@2x.png" + } + }, + { + "url": "/guides/install-vpp-apps-on-macos-using-fleet", + "title": "Install vpp apps on macos using Fleet", + "lastModifiedAt": 1726839805145, + "htmlId": "articles--install-vpp-apps-on---4e6a161ea8", + "sectionRelativeRepoPath": "install-vpp-apps-on-macos-using-fleet.md", + "meta": { + "articleTitle": "Install VPP apps on macOS, iOS, and iPadOS using Fleet", + "authorFullName": "Jahziel Villasana-Espinoza", + "authorGitHubUsername": "jahzielv", + "category": "guides", + "publishedOn": "2024-08-12", + "articleImageUrl": "/images/articles/install-vpp-apps-on-macos-using-fleet-1600x900@2x.png", + "description": "This guide will walk you through installing VPP apps on macOS, iOS, and iPadOS using Fleet." + } + }, + { + "url": "/announcements/introducing-cross-platform-script-execution", + "title": "Introducing cross platform script execution", + "lastModifiedAt": 1726839805147, + "htmlId": "articles--introducing-cross-pl--f50031e3db", + "sectionRelativeRepoPath": "introducing-cross-platform-script-execution.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-10-17", + "articleTitle": "Introducing cross-platform script execution", + "articleImageUrl": "/images/articles/introducing-cross-platform-script-execution-800x450@2x.png" + } + }, + { + "url": "/announcements/introducing-fleet-ultimate", + "title": "Introducing Fleet ultimate", + "lastModifiedAt": 1726839805147, + "htmlId": "articles--introducing-fleet-ul--caba265ec4", + "sectionRelativeRepoPath": "introducing-fleet-ultimate.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "jarodreyes", + "authorFullName": "Jarod Reyes", + "publishedOn": "2023-02-20", + "articleTitle": "Introducing CIS benchmarks, managed-cloud hosting and custom calculator in the new Fleet Ultimate plan.", + "articleImageUrl": "/images/articles/happy-1st-anniversary-fleet-cover-800x450@2x.png" + } + }, + { + "url": "/announcements/introducing-orbit-your-fleet-agent-manager", + "title": "Introducing orbit your Fleet agent manager", + "lastModifiedAt": 1726839805148, + "htmlId": "articles--introducing-orbit-yo--1de0ea07ab", + "sectionRelativeRepoPath": "introducing-orbit-your-fleet-agent-manager.md", + "meta": { + "category": "announcements", + "authorFullName": "Mo Zhu", + "authorGitHubUsername": "zhumo", + "publishedOn": "2022-08-18", + "articleTitle": "Introducing Orbit, your Fleet agent manager", + "articleImageUrl": "/images/articles/fleet-4.17.0-1-1600x900@2x.jpg" + } + }, + { + "url": "/engineering/linux-vulnerability-detection-with-oval-and-fleet", + "title": "Linux vulnerability detection with oval and Fleet", + "lastModifiedAt": 1726839805150, + "htmlId": "articles--linux-vulnerability---0d4c8fd5ac", + "sectionRelativeRepoPath": "linux-vulnerability-detection-with-oval-and-fleet.md", + "meta": { + "category": "engineering", + "authorGitHubUsername": "juan-fdz-hawa", + "authorFullName": "Juan Fernandes", + "publishedOn": "2022-07-29", + "articleTitle": "Linux vulnerability detection with OVAL and Fleet", + "articleImageUrl": "/images/articles/linux-vulnerability-detection-with-oval-and-fleet-1600x900@2x.jpg" + } + }, + { + "url": "/guides/locate-assets-with-osquery", + "title": "Locate assets with osquery", + "lastModifiedAt": 1726839805150, + "htmlId": "articles--locate-assets-with-o--764d2b5f55", + "sectionRelativeRepoPath": "locate-assets-with-osquery.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2021-05-11", + "articleTitle": "Locate device assets in the event of an emergency.", + "articleImageUrl": "/images/articles/locate-assets-with-osquery-cover-700x393@2x.jpeg" + } + }, + { + "url": "/guides/log-destinations", + "title": "Log destinations", + "lastModifiedAt": 1726839805152, + "htmlId": "articles--log-destinations--9bb62f5aa2", + "sectionRelativeRepoPath": "log-destinations.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "rachaelshaw", + "authorFullName": "Rachael Shaw", + "publishedOn": "2023-11-02", + "articleTitle": "Log destinations", + "description": "Learn about supported log destinations in Fleet, including Amazon Kinesis, AWS Lambda Snowflake, Splunk, and more." + } + }, + { + "url": "/securing/lossless-yubikeys-with-yubitrak-and-airtag", + "title": "Lossless yubikeys with yubitrak and airtag", + "lastModifiedAt": 1726839805153, + "htmlId": "articles--lossless-yubikeys-wi--b260bfc20a", + "sectionRelativeRepoPath": "lossless-yubiKeys-with-yubitrak-and-airtag.md", + "meta": { + "category": "security", + "authorGitHubUsername": "GuillaumeRoss", + "authorFullName": "Guillaume Ross", + "publishedOn": "2022-06-16", + "articleTitle": "Lossless YubiKeys with Yubitrak and AirTag", + "articleImageUrl": "/images/articles/lossless-yubikeys-with-yubitrak-and-airtag-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/macos-mdm-setup", + "title": "Macos mdm setup", + "lastModifiedAt": 1726839805154, + "htmlId": "articles--macos-mdm-setup--66538706f5", + "sectionRelativeRepoPath": "macos-mdm-setup.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zhumo", + "authorFullName": "Mo Zhu", + "publishedOn": "2024-07-02", + "articleTitle": "macOS MDM setup", + "description": "Learn how to turn on MDM features in Fleet." + } + }, + { + "url": "/guides/macos-setup-experience", + "title": "Macos setup experience", + "lastModifiedAt": 1726839805155, + "htmlId": "articles--macos-setup-experien--cca7e9e073", + "sectionRelativeRepoPath": "macos-setup-experience.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-03", + "articleTitle": "macOS setup experience", + "description": "Customize your macOS setup experience with Fleet Premium by managing user authentication, Setup Assistant panes, and installing bootstrap packages." + } + }, + { + "url": "/guides/managing-labels-in-fleet", + "title": "Managing labels in Fleet", + "lastModifiedAt": 1726839805156, + "htmlId": "articles--managing-labels-in-f--b2e5aed976", + "sectionRelativeRepoPath": "managing-labels-in-fleet.md", + "meta": { + "articleTitle": "Managing labels in Fleet", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-07-18", + "articleImageUrl": "/images/articles/managing-labels-in-fleet-1600x900@2x.png", + "description": "This guide will walk you through managing labels using the Fleet web UI." + } + }, + { + "url": "/securing/mapping-fleet-and-osquery-results-to-the-mitre-attck-framework-via-splunk", + "title": "Mapping Fleet and osquery results to the mitre attck framework via splunk", + "lastModifiedAt": 1726839805157, + "htmlId": "articles--mapping-fleet-and-os--7ee9249dc4", + "sectionRelativeRepoPath": "mapping-fleet-and-osquery-results-to-the-mitre-attck-framework-via-splunk.md", + "meta": { + "category": "security", + "authorFullName": "Dave Herder", + "authorGitHubUsername": "dherder", + "publishedOn": "2023-01-30", + "articleTitle": "Mapping Fleet and osquery results to the MITRE ATT&CK® framework via Splunk", + "articleImageUrl": "/images/articles/mapping-fleet-and-osquery-results-to-the-mitre-attck-framework-via-splunk-1600x900@2x.png" + } + }, + { + "url": "/guides/mdm-commands", + "title": "Mdm commands", + "lastModifiedAt": 1726839805158, + "htmlId": "articles--mdm-commands--8de440c455", + "sectionRelativeRepoPath": "mdm-commands.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-06-12", + "articleTitle": "MDM commands", + "description": "Learn how to run custom MDM commands on hosts using Fleet." + } + }, + { + "url": "/guides/mdm-migration", + "title": "Mdm migration", + "lastModifiedAt": 1726839805159, + "htmlId": "articles--mdm-migration--a500f61869", + "sectionRelativeRepoPath": "mdm-migration.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zhumo", + "authorFullName": "Mo Zhu", + "publishedOn": "2024-08-14", + "articleTitle": "MDM migration", + "description": "Instructions for migrating hosts away from an old MDM solution to Fleet." + } + }, + { + "url": "/announcements/nvd-api-2.0", + "title": "Nvd API 2.0", + "lastModifiedAt": 1726839805160, + "htmlId": "articles--nvd-api-20--a754d441c3", + "sectionRelativeRepoPath": "nvd-api-2.0.md", + "meta": { + "category": "announcements", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-11-28", + "articleTitle": "NVD API 2.0: An important update for Fleet users", + "articleImageUrl": "/images/articles/nvd-api-2.0-1600x900@2x.jpg" + } + }, + { + "url": "/securing/optimizing-government-cybersecurity-strategies", + "title": "Optimizing government cybersecurity strategies", + "lastModifiedAt": 1726839805161, + "htmlId": "articles--optimizing-governmen--78189d23a6", + "sectionRelativeRepoPath": "optimizing-government-cybersecurity-strategies.md", + "meta": { + "category": "security", + "authorFullName": "Keith Barnes", + "authorGitHubUsername": "KAB703", + "publishedOn": "2023-11-14", + "articleTitle": "Optimizing government cybersecurity strategies with Fleet.", + "articleImageUrl": "/images/articles/optimizing-government-cybersecurity-strategies-1600x900@2x.png" + } + }, + { + "url": "/releases/osquery-5.11.0", + "title": "Osquery 5.11.0", + "lastModifiedAt": 1726839805162, + "htmlId": "articles--osquery-5110--5af6435495", + "sectionRelativeRepoPath": "osquery-5.11.0.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2024-02-16", + "articleTitle": "osquery 5.11.0 | VSCode, Apple silicon, and more", + "articleImageUrl": "/images/articles/osquery-5.11.0-cover-1600x900@2x.png" + } + }, + { + "url": "/guides/osquery-a-tool-to-easily-ask-questions-about-operating-systems", + "title": "Osquery a tool to easily ask questions about operating systems", + "lastModifiedAt": 1726839805163, + "htmlId": "articles--osquery-a-tool-to-ea--424e2ed801", + "sectionRelativeRepoPath": "osquery-a-tool-to-easily-ask-questions-about-operating-systems.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "dominuskelvin", + "authorFullName": "Kelvin Omereshone", + "publishedOn": "2022-04-04", + "articleTitle": "Osquery: a tool to easily ask questions about operating systems", + "articleImageUrl": "/images/articles/osquery-a-tool-to-easily-ask-questions-about-operating-systems-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/osquery-as-a-threat-hunting-platform", + "title": "Osquery as a threat hunting platform", + "lastModifiedAt": 1726839805164, + "htmlId": "articles--osquery-as-a-threat---d96a59f1dd", + "sectionRelativeRepoPath": "osquery-as-a-threat-hunting-platform.md", + "meta": { + "category": "security", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-09-16", + "articleTitle": "Osquery… as a threat hunting platform?", + "articleImageUrl": "/images/articles/osquery-for-threat-hunting-1600x900@2x.jpg" + } + }, + { + "url": "/guides/osquery-consider-joining-against-the-users-table", + "title": "Osquery consider joining against the users table", + "lastModifiedAt": 1726839805165, + "htmlId": "articles--osquery-consider-joi--b99ae264e4", + "sectionRelativeRepoPath": "osquery-consider-joining-against-the-users-table.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2021-05-06", + "articleTitle": "Osquery: Consider joining against the users table", + "articleImageUrl": "/images/articles/osquery-consider-joining-against-the-users-table-cover-700x437@2x.jpeg" + } + }, + { + "url": "/releases/osquery-5.8.1", + "title": "Osquery 5.8.1", + "lastModifiedAt": 1726839805166, + "htmlId": "articles--osquery-581--5d3dced550", + "sectionRelativeRepoPath": "osquery-5.8.1.md", + "meta": { + "category": "releases", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "publishedOn": "2023-03-14", + "articleTitle": "osquery 5.8.1 | Process auditing, stats, and additional tables", + "articleImageUrl": "/images/articles/osquery-5.8.1-cover-1600x900@2x.png" + } + }, + { + "url": "/guides/osquery-evented-tables-overview", + "title": "Osquery evented tables overview", + "lastModifiedAt": 1726839805168, + "htmlId": "articles--osquery-evented-tabl--b9b1176562", + "sectionRelativeRepoPath": "osquery-evented-tables-overview.md", + "meta": { + "articleTitle": "How to use osquery evented tables", + "authorFullName": "Mo Zhu", + "authorGitHubUsername": "zhumo", + "category": "guides", + "publishedOn": "2022-09-21" + } + }, + { + "url": "/securing/osquery-vulnerability-management-at-scale", + "title": "Osquery vulnerability management at scale", + "lastModifiedAt": 1726839805169, + "htmlId": "articles--osquery-vulnerabilit--cac605ad18", + "sectionRelativeRepoPath": "osquery-vulnerability-management-at-scale.md", + "meta": { + "category": "security", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-10-05", + "articleTitle": "Vulnerability management at scale: a presentation from osquery Co-creator Zach Wasserman", + "articleImageUrl": "/images/articles/vulnerability-management-at-scale-with-osquery_800x450@2x.jpg" + } + }, + { + "url": "/guides/osquery-watchdog", + "title": "Osquery watchdog", + "lastModifiedAt": 1726839805170, + "htmlId": "articles--osquery-watchdog--6a195970e0", + "sectionRelativeRepoPath": "osquery-watchdog.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "juan-fdz-hawa", + "authorFullName": "Juan Fernandes", + "publishedOn": "2023-07-28", + "articleTitle": "Osquery watchdog", + "description": "Learn about how osquery process manages child processes and managed extensions in Fleet." + } + }, + { + "url": "/announcements/psu-macadmins-conference-2023", + "title": "Psu macadmins conference 2023", + "lastModifiedAt": 1726839805171, + "htmlId": "articles--psu-macadmins-confer--175629dfbd", + "sectionRelativeRepoPath": "psu-macadmins-conference-2023.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-07-13", + "articleTitle": "Mac admins summer camp ⛺ at PSU MacAdmins Conference 2023", + "articleImageUrl": "/images/articles/psu-macadmins-conference-2023@2x.png", + "description": "A look ahead to PSU MacAdmin Conference July 18-21, 2023" + } + }, + { + "url": "/guides/puppet-module", + "title": "Puppet module", + "lastModifiedAt": 1726839805172, + "htmlId": "articles--puppet-module--c01ecdf2b6", + "sectionRelativeRepoPath": "puppet-module.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-05-24", + "articleTitle": "Puppet module", + "description": "Learn how to use Fleet's Puppet module to automatically assign custom configuration profiles on your macOS hosts." + } + }, + { + "url": "/guides/queries", + "title": "Queries", + "lastModifiedAt": 1726839805173, + "htmlId": "articles--queries--ce5c1e3c99", + "sectionRelativeRepoPath": "queries.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-09", + "articleTitle": "Queries", + "description": "Learn how to create, run, and schedule queries, as well as update agent options in the Fleet user interface." + } + }, + { + "url": "/guides/querying-process-file-events-table-on-centos-7", + "title": "Querying process file events table on centos 7", + "lastModifiedAt": 1726839805174, + "htmlId": "articles--querying-process-fil--5587f39199", + "sectionRelativeRepoPath": "querying-process-file-events-table-on-centos-7.md", + "meta": { + "articleTitle": "Querying process_file_events on CentOS 7", + "description": "Learn how to configure and query the process_file_events table on CentOS 7 with Fleet.", + "category": "guides", + "authorGitHubUsername": "lucasmrod", + "authorFullName": "Lucas Rodriguez", + "publishedOn": "2023-07-17" + } + }, + { + "url": "/guides/role-based-access", + "title": "Role based access", + "lastModifiedAt": 1726839805177, + "htmlId": "articles--role-based-access--92a94667b2", + "sectionRelativeRepoPath": "role-based-access.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-08-10", + "articleTitle": "Role-based access", + "description": "Learn about the different roles and permissions in Fleet." + } + }, + { + "url": "/engineering/saving-over-100x-on-egress-switching-from-aws-to-hetzner", + "title": "Saving over 100x on egress switching from aws to hetzner", + "lastModifiedAt": 1726839805179, + "htmlId": "articles--saving-over-100x-on---a46f112fc0", + "sectionRelativeRepoPath": "saving-over-100x-on-egress-switching-from-aws-to-hetzner.md", + "meta": { + "category": "engineering", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-01-25", + "articleTitle": "Saving over 100x on egress switching from AWS to Hetzner", + "articleImageUrl": "/images/articles/saving-over-100x-on-egress-switching-from-aws-to-hetzner-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/scripts", + "title": "Scripts", + "lastModifiedAt": 1726839805179, + "htmlId": "articles--scripts--3a91ba655e", + "sectionRelativeRepoPath": "scripts.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-06-04", + "articleTitle": "Scripts", + "description": "Learn how to execute a custom script on macOS, Windows, and Linux hosts in Fleet." + } + }, + { + "url": "/guides/seamless-mdm-migration", + "title": "Seamless mdm migration", + "lastModifiedAt": 1726839805182, + "htmlId": "articles--seamless-mdm-migrati--f0abcf2f23", + "sectionRelativeRepoPath": "seamless-mdm-migration.md", + "meta": { + "category": "guides", + "authorFullName": "Zach Wasserman", + "authorGitHubUsername": "zwass", + "publishedOn": "2024-08-08", + "articleTitle": "Seamless MDM migrations to Fleet", + "articleImageUrl": "/images/articles/seamless-mdm-migration-1600x900@2x.png", + "description": "This guide provides a process for seamlessly migrating macOS devices from an existing MDM solution to Fleet." + } + }, + { + "url": "/announcements/seattle-bellevue-cyber-security-summit-march-8-2023", + "title": "Seattle bellevue cyber security summit march 8 2023", + "lastModifiedAt": 1726839805183, + "htmlId": "articles--seattle-bellevue-cyb--3b5ca28169", + "sectionRelativeRepoPath": "seattle-bellevue-cyber-security-summit-march-8-2023.md", + "meta": { + "category": "announcements", + "authorGitHubUsername": "spokanemac", + "authorFullName": "JD Strong", + "publishedOn": "2023-03-02", + "articleTitle": "Join Fleet at Cyber Security Summit Seattle/Bellevue", + "articleImageUrl": "/images/articles/seattle-bellevue-cyber-security-summit-social-post-1200x628@2x.png" + } + }, + { + "url": "/securing/security-testing-at-fleet-fleet-pentest", + "title": "Security testing at Fleet Fleet pentest", + "lastModifiedAt": 1726839805184, + "htmlId": "articles--security-testing-at---106e7f1999", + "sectionRelativeRepoPath": "security-testing-at-fleet-fleet-pentest.md", + "meta": { + "category": "security", + "authorGitHubUsername": "GuillaumeRoss", + "authorFullName": "Guillaume Ross", + "publishedOn": "2022-05-10", + "articleTitle": "Penetration testing of Fleet (April 2022)", + "articleImageUrl": "/images/articles/security-testing-at-fleet-fleet-pentest-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/security-testing-at-fleet-orbit-auto-updater-audit", + "title": "Security testing at Fleet orbit auto updater audit", + "lastModifiedAt": 1726839805185, + "htmlId": "articles--security-testing-at---f487015e45", + "sectionRelativeRepoPath": "security-testing-at-fleet-orbit-auto-updater-audit.md", + "meta": { + "category": "security", + "authorGitHubUsername": "GuillaumeRoss", + "authorFullName": "Guillaume Ross", + "publishedOn": "2022-03-30", + "articleTitle": "Security testing at Fleet/Orbit auto-updater audit", + "articleImageUrl": "/images/articles/security-testing-at-fleet-orbit-auto-updater-audit-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/software-self-service", + "title": "Software self service", + "lastModifiedAt": 1726839805186, + "htmlId": "articles--software-self-servic--9047e7f63d", + "sectionRelativeRepoPath": "software-self-service.md", + "meta": { + "articleTitle": "Software self-service", + "authorFullName": "Jahziel Villasana-Espinoza", + "authorGitHubUsername": "jahzielv", + "category": "guides", + "publishedOn": "2024-08-06", + "articleImageUrl": "/images/articles/software-self-service-1600x900@2x.png", + "description": "This guide will walk you through adding apps to Fleet for user self-service." + } + }, + { + "url": "/guides/standard-query-library", + "title": "Standard query library", + "lastModifiedAt": 1726839805187, + "htmlId": "articles--standard-query-libra--dccfaa84b4", + "sectionRelativeRepoPath": "standard-query-library.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-04-04", + "articleTitle": "Standard query library", + "description": "Learn how to use and contribute to Fleet's standard query library." + } + }, + { + "url": "/report/state-of-device-management", + "title": "State of device management", + "lastModifiedAt": 1726839805187, + "htmlId": "articles--state-of-device-mana--f6254cc69c", + "sectionRelativeRepoPath": "state-of-device-management.md", + "meta": { + "category": "report", + "authorFullName": "Mike McNeil", + "authorGitHubUsername": "mikermcneil", + "publishedOn": "2022-07-07", + "articleTitle": "State of Device Management report 2022", + "articleImageUrl": "/images/articles/state-of-device-management-report-1600x900@2x.png" + } + }, + { + "url": "/securing/stay-on-course-with-your-security-compliance-goals", + "title": "Stay on course with your security compliance goals", + "lastModifiedAt": 1726839805188, + "htmlId": "articles--stay-on-course-with---a487f310dc", + "sectionRelativeRepoPath": "stay-on-course-with-your-security-compliance-goals.md", + "meta": { + "category": "security", + "authorFullName": "Chris McGillicuddy", + "authorGitHubUsername": "chris-mcgillicuddy", + "publishedOn": "2022-07-18", + "articleTitle": "Stay on course with your security compliance goals", + "articleImageUrl": "/images/articles/security-compliance-goals-cover-800x450@2x.jpg" + } + }, + { + "url": "/guides/sysadmin-diaries-device-enrollment", + "title": "Sysadmin diaries device enrollment", + "lastModifiedAt": 1726839805189, + "htmlId": "articles--sysadmin-diaries-dev--abfda23f04", + "sectionRelativeRepoPath": "sysadmin-diaries-device-enrollment.md", + "meta": { + "articleTitle": "Sysadmin diaries: device enrollment", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-05-03", + "articleImageUrl": "/images/articles/sysadmin-diaries-1600x900@2x.png", + "description": "In this sysadmin diary, we explore a the differences in device enrollment." + } + }, + { + "url": "/guides/sysadmin-diaries-exporting-policies", + "title": "Sysadmin diaries exporting policies", + "lastModifiedAt": 1726839805190, + "htmlId": "articles--sysadmin-diaries-exp--a101d98c97", + "sectionRelativeRepoPath": "sysadmin-diaries-exporting-policies.md", + "meta": { + "articleTitle": "Sysadmin diaries: exporting policies", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-06-28", + "articleImageUrl": "/images/articles/sysadmin-diaries-1600x900@2x.png", + "description": "In this sysadmin diary, we explore extracting existing policies to enable gitops." + } + }, + { + "url": "/guides/sysadmin-diaries-lost-device", + "title": "Sysadmin diaries lost device", + "lastModifiedAt": 1726839805191, + "htmlId": "articles--sysadmin-diaries-los--3bcb909203", + "sectionRelativeRepoPath": "sysadmin-diaries-lost-device.md", + "meta": { + "articleTitle": "Sysadmin diaries: lost device", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-07-09", + "articleImageUrl": "/images/articles/sysadmin-diaries-1600x900@2x.png", + "description": "In this sysadmin diary, we explore what actions can be taken with Fleet when a device is lost." + } + }, + { + "url": "/guides/sysadmin-diaries-passcode-profiles", + "title": "Sysadmin diaries passcode profiles", + "lastModifiedAt": 1726839805192, + "htmlId": "articles--sysadmin-diaries-pas--883471875d", + "sectionRelativeRepoPath": "sysadmin-diaries-passcode-profiles.md", + "meta": { + "articleTitle": "Sysadmin diaries: passcode profiles", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-04-01", + "articleImageUrl": "/images/articles/sysadmin-diaries-1600x900@2x.png", + "description": "In this sysadmin diary, we explore a missapplied passcode policy." + } + }, + { + "url": "/guides/sysadmin-diaries-restoring-fleetd", + "title": "Sysadmin diaries restoring fleetd", + "lastModifiedAt": 1726839805193, + "htmlId": "articles--sysadmin-diaries-res--96c547e138", + "sectionRelativeRepoPath": "sysadmin-diaries-restoring-fleetd.md", + "meta": { + "articleTitle": "Sysadmin diaries: restoring fleetd", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2024-06-14", + "articleImageUrl": "/images/articles/sysadmin-diaries-1600x900@2x.png", + "description": "In this sysadmin diary, we explore restoring fleetd deleted by a surly employee." + } + }, + { + "url": "/securing/tales-from-fleet-security-github-configuration-and-openssf-scorecards", + "title": "Tales from Fleet security github configuration and openssf scorecards", + "lastModifiedAt": 1726839805194, + "htmlId": "articles--tales-from-fleet-sec--035c3d6474", + "sectionRelativeRepoPath": "tales-from-fleet-security-github-configuration-and-openssf-scorecards.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-04-15", + "articleTitle": "Tales from Fleet security: GitHub configuration and OpenSSF Scorecards", + "articleImageUrl": "/images/articles/tales-from-fleet-security-github-configuration-and-openssf-scorecards-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-google-groups-scams", + "title": "Tales from Fleet security google groups scams", + "lastModifiedAt": 1726839805195, + "htmlId": "articles--tales-from-fleet-sec--841598a71f", + "sectionRelativeRepoPath": "tales-from-fleet-security-google-groups-scams.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-08-05", + "articleTitle": "Tales from Fleet security: scams targeting Google Groups", + "articleImageUrl": "/images/articles/tales-from-fleet-security-google-groups-scams-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-securing-1password", + "title": "Tales from Fleet security securing 1password", + "lastModifiedAt": 1726839805196, + "htmlId": "articles--tales-from-fleet-sec--d172f39898", + "sectionRelativeRepoPath": "tales-from-fleet-security-securing-1password.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-05-06", + "articleTitle": "Tales from Fleet security: securing 1Password", + "articleImageUrl": "/images/articles/tales-from-fleet-security-securing-1password-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-securing-bank-accounts-from-business-email-compromise", + "title": "Tales from Fleet security securing bank accounts from business email compromise", + "lastModifiedAt": 1726839805198, + "htmlId": "articles--tales-from-fleet-sec--f60a0becab", + "sectionRelativeRepoPath": "tales-from-fleet-security-securing-bank-accounts-from-business-email-compromise.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-07-15", + "articleTitle": "Tales from Fleet security: securing bank accounts from business email compromise", + "articleImageUrl": "/images/articles/securing-bank-accounts-from-business-email-compromise-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-securing-google-workspace", + "title": "Tales from Fleet security securing google workspace", + "lastModifiedAt": 1726839805199, + "htmlId": "articles--tales-from-fleet-sec--72efc9f80f", + "sectionRelativeRepoPath": "tales-from-fleet-security-securing-google-workspace.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-03-25", + "articleTitle": "Tales from Fleet security: securing Google Workspace", + "articleImageUrl": "/images/articles/tales-from-fleet-security-securing-google-workspace-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-securing-the-startup", + "title": "Tales from Fleet security securing the startup", + "lastModifiedAt": 1726839805200, + "htmlId": "articles--tales-from-fleet-sec--a25132f487", + "sectionRelativeRepoPath": "tales-from-fleet-security-securing-the-startup.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-03-17", + "articleTitle": "Tales from Fleet security: securing the startup", + "articleImageUrl": "/images/articles/tales-from-fleet-security-securing-the-startup-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-soc2", + "title": "Tales from Fleet security soc2", + "lastModifiedAt": 1726839805202, + "htmlId": "articles--tales-from-fleet-sec--f537169e1e", + "sectionRelativeRepoPath": "tales-from-fleet-security-soc2.md", + "meta": { + "category": "security", + "authorGitHubUsername": "GuillaumeRoss", + "authorFullName": "Guillaume Ross", + "publishedOn": "2022-06-24", + "articleTitle": "Tales from Fleet security: how we achieved our SOC 2 type 1 rapidly", + "articleImageUrl": "/images/articles/tales-from-fleet-soc2-type1-report-cover-1600x900@2x.jpg" + } + }, + { + "url": "/securing/tales-from-fleet-security-speeding-up-macos-updates-with-nudge", + "title": "Tales from Fleet security speeding up macos updates with nudge", + "lastModifiedAt": 1726839805203, + "htmlId": "articles--tales-from-fleet-sec--41bc496d3c", + "sectionRelativeRepoPath": "tales-from-fleet-security-speeding-up-macos-updates-with-nudge.md", + "meta": { + "category": "security", + "authorFullName": "Guillaume Ross", + "authorGitHubUsername": "GuillaumeRoss", + "publishedOn": "2022-07-05", + "articleTitle": "Tales from Fleet security: speeding up macOS updates with Nudge", + "articleImageUrl": "/images/articles/tales-from-fleet-nudge-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/teams", + "title": "Teams", + "lastModifiedAt": 1726839805204, + "htmlId": "articles--teams--a6aba53335", + "sectionRelativeRepoPath": "teams.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-11", + "articleTitle": "Teams", + "description": "Learn how to group hosts in Fleet to apply specific queries, policies, and agent options using teams." + } + }, + { + "url": "/announcements/the-device-security-tightrope-balancing-cost-and-protection-in-k-12-schools", + "title": "The device security tightrope balancing cost and protection in k 12 schools", + "lastModifiedAt": 1726839805205, + "htmlId": "articles--the-device-security---cba806f3e1", + "sectionRelativeRepoPath": "the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools.md", + "meta": { + "category": "announcements", + "authorFullName": "Keith Barnes", + "authorGitHubUsername": "KAB703", + "publishedOn": "2024-03-01", + "articleTitle": "The device security tightrope: balancing cost and protection in K-12 schools", + "articleImageUrl": "/images/articles/the-device-security-tightrope-balancing-cost-and-protection-in-K-12-schools-1600x900@2x.png" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep1", + "title": "The future of device management ep1", + "lastModifiedAt": 1726839805207, + "htmlId": "articles--the-future-of-device--e424a67517", + "sectionRelativeRepoPath": "the-future-of-device-management-ep1.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-06-06", + "articleTitle": "Future of device management episode 1", + "articleImageUrl": "/images/articles/future-of-device-management-ep1-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep2", + "title": "The future of device management ep2", + "lastModifiedAt": 1726839805208, + "htmlId": "articles--the-future-of-device--0b4ec299db", + "sectionRelativeRepoPath": "the-future-of-device-management-ep2.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-06-30", + "articleTitle": "Future of device management episode 2", + "articleImageUrl": "/images/articles/future-of-device-management-ep2-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep3", + "title": "The future of device management ep3", + "lastModifiedAt": 1726839805209, + "htmlId": "articles--the-future-of-device--d7b8d1fbfe", + "sectionRelativeRepoPath": "the-future-of-device-management-ep3.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-07-21", + "articleTitle": "Future of device management episode 3", + "articleImageUrl": "/images/articles/future-of-device-management-ep3-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep4", + "title": "The future of device management ep4", + "lastModifiedAt": 1726839805210, + "htmlId": "articles--the-future-of-device--bd6c88c590", + "sectionRelativeRepoPath": "the-future-of-device-management-ep4.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-08-12", + "articleTitle": "Future of device management episode 4", + "articleImageUrl": "/images/articles/future-of-device-management-ep4-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep5", + "title": "The future of device management ep5", + "lastModifiedAt": 1726839805210, + "htmlId": "articles--the-future-of-device--c5ce4719fa", + "sectionRelativeRepoPath": "the-future-of-device-management-ep5.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-09-01", + "articleTitle": "Future of device management episode 5", + "articleImageUrl": "/images/articles/future-of-device-management-ep5-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep6", + "title": "The future of device management ep6", + "lastModifiedAt": 1726839805211, + "htmlId": "articles--the-future-of-device--141153d341", + "sectionRelativeRepoPath": "the-future-of-device-management-ep6.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-09-23", + "articleTitle": "Future of device management episode 6", + "articleImageUrl": "/images/articles/future-of-device-management-ep6-cover-1600x900@2x.jpg" + } + }, + { + "url": "/podcasts/the-future-of-device-management-ep7", + "title": "The future of device management ep7", + "lastModifiedAt": 1726839805212, + "htmlId": "articles--the-future-of-device--52a1db0bde", + "sectionRelativeRepoPath": "the-future-of-device-management-ep7.md", + "meta": { + "category": "podcasts", + "authorGitHubUsername": "zwass", + "authorFullName": "Zach Wasserman", + "publishedOn": "2022-11-03", + "articleTitle": "Future of device management episode 7", + "articleImageUrl": "/images/articles/future-of-device-management-ep7-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/understanding-the-intricacies-of-fleet-policies", + "title": "Understanding the intricacies of Fleet policies", + "lastModifiedAt": 1726839805213, + "htmlId": "articles--understanding-the-in--edae4ca064", + "sectionRelativeRepoPath": "understanding-the-intricacies-of-fleet-policies.md", + "meta": { + "articleTitle": "Understanding the intricacies of Fleet policies", + "authorFullName": "Victor Lyuboslavsky", + "authorGitHubUsername": "getvictor", + "category": "guides", + "publishedOn": "2023-12-29", + "description": "Learn how Fleet policies work behind the scenes." + } + }, + { + "url": "/guides/using-elasticsearch-and-kibana-to-visualize-osquery-performance", + "title": "Using elasticsearch and kibana to visualize osquery performance", + "lastModifiedAt": 1726839805215, + "htmlId": "articles--using-elasticsearch---55019ee35a", + "sectionRelativeRepoPath": "using-elasticsearch-and-kibana-to-visualize-osquery-performance.md", + "meta": { + "category": "guides", + "authorFullName": "Zach Wasserman", + "authorGitHubUsername": "zwass", + "publishedOn": "2021-05-26", + "articleTitle": "Using Elasticsearch and Kibana to visualize osquery performance", + "articleImageUrl": "/images/articles/using-elasticsearch-and-kibana-to-visualize-osquery-performance-cover-700x393@2x.jpeg" + } + }, + { + "url": "/guides/using-fleet-and-okta-workflows-to-generate-a-daily-os-report", + "title": "Using Fleet and okta workflows to generate a daily os report", + "lastModifiedAt": 1726839805218, + "htmlId": "articles--using-fleet-and-okta--a4676a8577", + "sectionRelativeRepoPath": "using-fleet-and-okta-workflows-to-generate-a-daily-os-report.md", + "meta": { + "articleTitle": "Using Fleet and Okta Workflows to generate a daily OS report", + "authorFullName": "Harrison Ravazzolo", + "authorGitHubUsername": "harrisonravazzolo", + "category": "guides", + "publishedOn": "2023-05-09", + "articleImageUrl": "/images/articles/using-fleet-and-okta-workflows-to-generate-a-daily-os-report@2x.jpg", + "description": "Learn how to use Fleet to query device OS information through the Fleet REST API and automate daily Slack notifications using Okta Workflows." + } + }, + { + "url": "/guides/using-fleet-and-tines-together", + "title": "Using Fleet and tines together", + "lastModifiedAt": 1726839805219, + "htmlId": "articles--using-fleet-and-tine--3606f85672", + "sectionRelativeRepoPath": "using-fleet-and-tines-together.md", + "meta": { + "category": "guides", + "authorFullName": "Dave Herder", + "authorGitHubUsername": "dherder", + "publishedOn": "2023-03-08", + "articleTitle": "Using Fleet and Tines together", + "articleImageUrl": "/images/articles/using-fleet-and-tines-together-1600x900@2x.png" + } + }, + { + "url": "/guides/using-github-actions-to-apply-configuration-profiles-with-fleet", + "title": "Using github actions to apply configuration profiles with Fleet", + "lastModifiedAt": 1726839805220, + "htmlId": "articles--using-github-actions--d966ed0177", + "sectionRelativeRepoPath": "using-github-actions-to-apply-configuration-profiles-with-fleet.md", + "meta": { + "articleTitle": "Using GitHub Actions to apply configuration profiles with Fleet", + "authorFullName": "JD Strong", + "authorGitHubUsername": "spokanemac", + "category": "guides", + "publishedOn": "2023-05-31", + "articleImageUrl": "/images/articles/using-github-actions-to-apply-configuration-profiles-with-fleet@2x.jpg", + "description": "A guide on using GitHub Actions with Fleet for efficient and automated application of the latest configuration profiles for a GitOps workflow." + } + }, + { + "url": "/securing/vulnerability-management-the-advantages-of-fleet-to-support-government-agencies", + "title": "Vulnerability management the advantages of Fleet to support government agencies", + "lastModifiedAt": 1726839805221, + "htmlId": "articles--vulnerability-manage--fae19ad566", + "sectionRelativeRepoPath": "vulnerability-management-the-advantages-of-fleet-to-support-government-agencies.md", + "meta": { + "category": "security", + "authorFullName": "Keith Barnes", + "authorGitHubUsername": "KAB703", + "publishedOn": "2023-12-26", + "articleTitle": "Vulnerability management: advantages of Fleet to support government agencies", + "articleImageUrl": "/images/articles/vulnerability-management-advantages-of-fleet-to-support-government-agencies-1600x900@2x.png" + } + }, + { + "url": "/guides/vulnerability-processing", + "title": "Vulnerability processing", + "lastModifiedAt": 1726839805222, + "htmlId": "articles--vulnerability-proces--244a2b70ee", + "sectionRelativeRepoPath": "vulnerability-processing.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "noahtalerman", + "authorFullName": "Noah Talerman", + "publishedOn": "2024-07-12", + "articleTitle": "Vulnerability processing", + "description": "Find out how Fleet detects vulnerabilities and what software it covers." + } + }, + { + "url": "/guides/what-api-endpoints-to-expose-to-the-public-internet", + "title": "What API endpoints to expose to the public internet", + "lastModifiedAt": 1726839805223, + "htmlId": "articles--what-api-endpoints-t--cd0552d444", + "sectionRelativeRepoPath": "what-api-endpoints-to-expose-to-the-public-internet.md", + "meta": { + "category": "guides", + "authorGitHubUsername": "mike-j-thomas", + "authorFullName": "Mike Thomas", + "publishedOn": "2023-11-13", + "articleTitle": "Which API endpoints to expose to the public internet?" + } + }, + { + "url": "/securing/what-are-fleet-policies", + "title": "What are Fleet policies", + "lastModifiedAt": 1726839805224, + "htmlId": "articles--what-are-fleet-polic--d8ca2da611", + "sectionRelativeRepoPath": "what-are-fleet-policies.md", + "meta": { + "category": "security", + "authorGitHubUsername": "Drew-P-drawers", + "authorFullName": "Andrew Baker", + "publishedOn": "2022-05-20", + "articleTitle": "What are Fleet policies?", + "articleImageUrl": "/images/articles/what-are-fleet-policies-cover-1600x900@2x.jpg" + } + }, + { + "url": "/guides/windows-mdm-setup", + "title": "Windows mdm setup", + "lastModifiedAt": 1726839805226, + "htmlId": "articles--windows-mdm-setup--ebf4ebf0ba", + "sectionRelativeRepoPath": "windows-mdm-setup.md", + "meta": { + "articleTitle": "Windows MDM setup", + "authorFullName": "Noah Talerman", + "authorGitHubUsername": "noahtalerman", + "category": "guides", + "publishedOn": "2023-10-23", + "articleImageUrl": "/images/articles/windows-mdm-fleet-1600x900@2x.png", + "description": "Configuring Windows MDM in Fleet." + } + }, + { + "url": "/guides/zero-trust-attestation-with-fleet", + "title": "Zero trust attestation with Fleet", + "lastModifiedAt": 1726839805227, + "htmlId": "articles--zero-trust-attestati--b892a54252", + "sectionRelativeRepoPath": "zero-trust-attestation-with-fleet.md", + "meta": { + "articleTitle": "How to use Fleet for zero trust attestation", + "authorFullName": "Mo Zhu", + "authorGitHubUsername": "zhumo", + "category": "guides", + "publishedOn": "2022-10-14", + "articleImageUrl": "/images/articles/fleet-for-zero-trust-attestation-800x450@2x.jpg" + } + }, + { + "url": "/securing/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think", + "title": "Work may be watching but it might not be as bad as you think", + "lastModifiedAt": 1726839805227, + "htmlId": "articles--work-may-be-watching--420e065d2f", + "sectionRelativeRepoPath": "work-may-be-watching-but-it-might-not-be-as-bad-as-you-think.md", + "meta": { + "category": "security", + "authorFullName": "Mike Thomas", + "authorGitHubUsername": "mike-j-thomas", + "publishedOn": "2021-10-22", + "articleTitle": "Work may be watching, but it might not be as bad as you think.", + "articleImageUrl": "/images/articles/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think-cover-1600x900@2x.jpg" + } + }, + { + "url": "/handbook/company/open-positions/software-engineer", + "title": "🚀 Software Engineer", + "lastModifiedAt": 1726839805228, + "htmlId": "handbook--software-engineer--be50029cfb", + "sectionRelativeRepoPath": "company/open-positions.yml", + "meta": { + "maintainedBy": "LukeHeath" + } + }, + { + "url": "/handbook/company/open-positions/account-executive", + "title": "🐋 Account Executive", + "lastModifiedAt": 1726839805228, + "htmlId": "handbook--account-executive--d5def7dc8f", + "sectionRelativeRepoPath": "company/open-positions.yml", + "meta": { + "maintainedBy": "alexmitchelliii" + } + }, + { + "url": "/tables/account_policy_data", + "title": "account_policy_data", + "htmlId": "table--accountpolicydata--31df68b22b", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "account_policy_data", + "creation_time", + "failed_login_count", + "failed_login_timestamp", + "password_last_set_time", + "uid" + ], + "sectionRelativeRepoPath": "account_policy_data", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/account_policy_data.yml" + }, + { + "url": "/tables/ad_config", + "title": "ad_config", + "htmlId": "table--adconfig--39d2211d09", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "ad_config", + "domain", + "name", + "option", + "value" + ], + "sectionRelativeRepoPath": "ad_config", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ad_config.yml" + }, + { + "url": "/tables/alf", + "title": "alf", + "htmlId": "table--alf--4c28031b0f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "alf", + "allow_signed_enabled", + "firewall_unload", + "global_state", + "logging_enabled", + "logging_option", + "stealth_enabled", + "version" + ], + "sectionRelativeRepoPath": "alf", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/alf.yml" + }, + { + "url": "/tables/alf_exceptions", + "title": "alf_exceptions", + "htmlId": "table--alfexceptions--1fbd2a6157", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "alf_exceptions", + "path", + "state" + ], + "sectionRelativeRepoPath": "alf_exceptions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/alf_exceptions.yml" + }, + { + "url": "/tables/alf_explicit_auths", + "title": "alf_explicit_auths", + "htmlId": "table--alfexplicitauths--4b47436520", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "alf_explicit_auths", + "process" + ], + "sectionRelativeRepoPath": "alf_explicit_auths", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/alf_explicit_auths.yml" + }, + { + "url": "/tables/apfs_physical_stores", + "title": "apfs_physical_stores", + "htmlId": "table--apfsphysicalstores--30af4e1d13", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "apfs_physical_stores", + "container_capacity_ceiling", + "container_capacity_free", + "container_designated_physical_store", + "container_fusion", + "container_reference", + "container_uuid", + "identifier", + "size", + "uuid" + ], + "sectionRelativeRepoPath": "apfs_physical_stores", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apfs_physical_stores.yml" + }, + { + "url": "/tables/apfs_volumes", + "title": "apfs_volumes", + "htmlId": "table--apfsvolumes--d8e8cc281d", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "apfs_volumes", + "capacity_in_use", + "capacity_quota", + "capacity_reserve", + "container_capacity_ceiling", + "container_capacity_free", + "container_designated_physical_store", + "container_fusion", + "container_reference", + "container_uuid", + "crypto_migration_on", + "device_identifier", + "encryption", + "filevault", + "locked", + "name", + "role", + "uuid" + ], + "sectionRelativeRepoPath": "apfs_volumes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apfs_volumes.yml" + }, + { + "url": "/tables/app_icons", + "title": "app_icons", + "htmlId": "table--appicons--93bed0002f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "app_icons", + "hash", + "icon", + "path" + ], + "sectionRelativeRepoPath": "app_icons", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/app_icons.yml" + }, + { + "url": "/tables/app_schemes", + "title": "app_schemes", + "htmlId": "table--appschemes--e75c685f8d", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "app_schemes", + "enabled", + "external", + "handler", + "protected", + "scheme" + ], + "sectionRelativeRepoPath": "app_schemes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/app_schemes.yml" + }, + { + "url": "/tables/apparmor_events", + "title": "apparmor_events", + "htmlId": "table--apparmorevents--1b9b1af186", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "apparmor_events", + "apparmor", + "capability", + "capname", + "comm", + "denied_mask", + "eid", + "error", + "fsuid", + "info", + "label", + "message", + "name", + "namespace", + "operation", + "ouid", + "parent", + "pid", + "profile", + "requested_mask", + "time", + "type", + "uptime" + ], + "sectionRelativeRepoPath": "apparmor_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fapparmor_events.yml&value=name%3A%20apparmor_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/apparmor_profiles", + "title": "apparmor_profiles", + "htmlId": "table--apparmorprofiles--49f0f69437", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "apparmor_profiles", + "attach", + "mode", + "name", + "path", + "sha1" + ], + "sectionRelativeRepoPath": "apparmor_profiles", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fapparmor_profiles.yml&value=name%3A%20apparmor_profiles%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/appcompat_shims", + "title": "appcompat_shims", + "htmlId": "table--appcompatshims--33b5da402f", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "appcompat_shims", + "description", + "executable", + "install_time", + "path", + "sdb_id", + "type" + ], + "sectionRelativeRepoPath": "appcompat_shims", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fappcompat_shims.yml&value=name%3A%20appcompat_shims%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/apps", + "title": "apps", + "htmlId": "table--apps--ccdee150a9", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "apps", + "applescript_enabled", + "bundle_executable", + "bundle_identifier", + "bundle_name", + "bundle_package_type", + "bundle_short_version", + "bundle_version", + "category", + "compiler", + "copyright", + "development_region", + "display_name", + "element", + "environment", + "info_string", + "last_opened_time", + "minimum_system_version", + "name", + "path" + ], + "sectionRelativeRepoPath": "apps", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apps.yml" + }, + { + "url": "/tables/apt_sources", + "title": "apt_sources", + "htmlId": "table--aptsources--a209051a90", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "apt_sources", + "architectures", + "base_uri", + "components", + "maintainer", + "name", + "pid_with_namespace", + "release", + "source", + "version" + ], + "sectionRelativeRepoPath": "apt_sources", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apt_sources.yml" + }, + { + "url": "/tables/arp_cache", + "title": "arp_cache", + "htmlId": "table--arpcache--83f95510b6", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "arp_cache", + "address", + "interface", + "mac", + "permanent" + ], + "sectionRelativeRepoPath": "arp_cache", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/arp_cache.yml" + }, + { + "url": "/tables/asl", + "title": "asl", + "htmlId": "table--asl--d2accdbfe3", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "asl", + "extra", + "facility", + "gid", + "host", + "level", + "message", + "pid", + "ref_pid", + "ref_proc", + "sender", + "time", + "time_nano_sec", + "uid" + ], + "sectionRelativeRepoPath": "asl", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/asl.yml" + }, + { + "url": "/tables/augeas", + "title": "augeas", + "htmlId": "table--augeas--b316cda7a7", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "augeas", + "label", + "node", + "path", + "value" + ], + "sectionRelativeRepoPath": "augeas", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/augeas.yml" + }, + { + "url": "/tables/authdb", + "title": "authdb", + "htmlId": "table--authdb--a304d751e5", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "authdb", + "json_result", + "right_name" + ], + "sectionRelativeRepoPath": "authdb", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/authdb.yml" + }, + { + "url": "/tables/authenticode", + "title": "authenticode", + "htmlId": "table--authenticode--0de9da48eb", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "authenticode", + "issuer_name", + "original_program_name", + "path", + "result", + "serial_number", + "subject_name" + ], + "sectionRelativeRepoPath": "authenticode", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fauthenticode.yml&value=name%3A%20authenticode%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/authorization_mechanisms", + "title": "authorization_mechanisms", + "htmlId": "table--authorizationmechanisms--d2490cb436", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "authorization_mechanisms", + "entry", + "label", + "mechanism", + "plugin", + "privileged" + ], + "sectionRelativeRepoPath": "authorization_mechanisms", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/authorization_mechanisms.yml" + }, + { + "url": "/tables/authorizations", + "title": "authorizations", + "htmlId": "table--authorizations--7fb6b733e8", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "authorizations", + "allow_root", + "authenticate_user", + "class", + "comment", + "created", + "label", + "modified", + "session_owner", + "shared", + "timeout", + "tries", + "version" + ], + "sectionRelativeRepoPath": "authorizations", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/authorizations.yml" + }, + { + "url": "/tables/authorized_keys", + "title": "authorized_keys", + "htmlId": "table--authorizedkeys--5108700dee", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "authorized_keys", + "algorithm", + "comment", + "key", + "key_file", + "options", + "pid_with_namespace", + "uid" + ], + "sectionRelativeRepoPath": "authorized_keys", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/authorized_keys.yml" + }, + { + "url": "/tables/autoexec", + "title": "autoexec", + "htmlId": "table--autoexec--ab98111b94", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "autoexec", + "name", + "path", + "source" + ], + "sectionRelativeRepoPath": "autoexec", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fautoexec.yml&value=name%3A%20autoexec%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/azure_instance_metadata", + "title": "azure_instance_metadata", + "htmlId": "table--azureinstancemetadata--01df1dde23", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "azure_instance_metadata", + "location", + "name", + "offer", + "os_type", + "placement_group_id", + "platform_fault_domain", + "platform_update_domain", + "publisher", + "resource_group_name", + "sku", + "subscription_id", + "version", + "vm_id", + "vm_scale_set_name", + "vm_size", + "zone" + ], + "sectionRelativeRepoPath": "azure_instance_metadata", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/azure_instance_metadata.yml" + }, + { + "url": "/tables/azure_instance_tags", + "title": "azure_instance_tags", + "htmlId": "table--azureinstancetags--166e2b6f18", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "azure_instance_tags", + "key", + "value", + "vm_id" + ], + "sectionRelativeRepoPath": "azure_instance_tags", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/azure_instance_tags.yml" + }, + { + "url": "/tables/background_activities_moderator", + "title": "background_activities_moderator", + "htmlId": "table--backgroundactivitiesmoderator--12072ab407", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "background_activities_moderator", + "last_execution_time", + "path", + "sid" + ], + "sectionRelativeRepoPath": "background_activities_moderator", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fbackground_activities_moderator.yml&value=name%3A%20background_activities_moderator%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/battery", + "title": "battery", + "htmlId": "table--battery--e54a7e368b", + "evented": false, + "platforms": [ + "darwin", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "battery", + "amperage", + "charged", + "charging", + "chemistry", + "condition", + "current_capacity", + "cycle_count", + "designed_capacity", + "health", + "manufacture_date", + "manufacturer", + "max_capacity", + "minutes_to_full_charge", + "minutes_until_empty", + "model", + "percent_remaining", + "serial_number", + "state", + "voltage" + ], + "sectionRelativeRepoPath": "battery", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/battery.yml" + }, + { + "url": "/tables/bitlocker_info", + "title": "bitlocker_info", + "htmlId": "table--bitlockerinfo--277b4f7713", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "bitlocker_info", + "conversion_status", + "device_id", + "drive_letter", + "encryption_method", + "lock_status", + "percentage_encrypted", + "persistent_volume_id", + "protection_status", + "version" + ], + "sectionRelativeRepoPath": "bitlocker_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/bitlocker_info.yml" + }, + { + "url": "/tables/block_devices", + "title": "block_devices", + "htmlId": "table--blockdevices--3db1d23d7b", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "block_devices", + "block_size", + "label", + "model", + "name", + "parent", + "size", + "type", + "uuid", + "vendor" + ], + "sectionRelativeRepoPath": "block_devices", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/block_devices.yml" + }, + { + "url": "/tables/bpf_process_events", + "title": "bpf_process_events", + "htmlId": "table--bpfprocessevents--f98d50f0c4", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "bpf_process_events", + "cid", + "cmdline", + "cwd", + "duration", + "eid", + "exit_code", + "gid", + "json_cmdline", + "ntime", + "parent", + "path", + "pid", + "probe_error", + "syscall", + "tid", + "time", + "uid" + ], + "sectionRelativeRepoPath": "bpf_process_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fbpf_process_events.yml&value=name%3A%20bpf_process_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/bpf_socket_events", + "title": "bpf_socket_events", + "htmlId": "table--bpfsocketevents--2bbe58be1b", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "bpf_socket_events", + "cid", + "duration", + "eid", + "exit_code", + "family", + "fd", + "gid", + "local_address", + "local_port", + "ntime", + "parent", + "path", + "pid", + "probe_error", + "protocol", + "remote_address", + "remote_port", + "syscall", + "tid", + "time", + "type", + "uid" + ], + "sectionRelativeRepoPath": "bpf_socket_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fbpf_socket_events.yml&value=name%3A%20bpf_socket_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/carbon_black_info", + "title": "carbon_black_info", + "htmlId": "table--carbonblackinfo--1a7333701d", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "carbon_black_info", + "binary_queue", + "collect_cross_processes", + "collect_data_file_writes", + "collect_emet_events", + "collect_file_mods", + "collect_module_info", + "collect_module_loads", + "collect_net_conns", + "collect_process_user_context", + "collect_processes", + "collect_reg_mods", + "collect_sensor_operations", + "collect_store_files", + "config_name", + "event_queue", + "log_file_disk_quota_mb", + "log_file_disk_quota_percentage", + "protection_disabled", + "sensor_backend_server", + "sensor_id", + "sensor_ip_addr" + ], + "sectionRelativeRepoPath": "carbon_black_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/carbon_black_info.yml" + }, + { + "url": "/tables/carves", + "title": "carves", + "htmlId": "table--carves--faab2a865e", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "carves", + "carve", + "carve_guid", + "path", + "request_id", + "sha256", + "size", + "status", + "time" + ], + "sectionRelativeRepoPath": "carves", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fcarves.yml&value=name%3A%20carves%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/certificates", + "title": "certificates", + "htmlId": "table--certificates--e853dcf612", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "certificates", + "authority_key_id", + "ca", + "common_name", + "issuer", + "issuer2", + "key_algorithm", + "key_strength", + "key_usage", + "not_valid_after", + "not_valid_before", + "path", + "self_signed", + "serial", + "sha1", + "sid", + "signing_algorithm", + "store", + "store_id", + "store_location", + "subject", + "subject2", + "subject_key_id", + "username" + ], + "sectionRelativeRepoPath": "certificates", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/certificates.yml" + }, + { + "url": "/tables/chassis_info", + "title": "chassis_info", + "htmlId": "table--chassisinfo--b4f2a373fd", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "chassis_info", + "audible_alarm", + "breach_description", + "chassis_types", + "description", + "lock", + "manufacturer", + "model", + "security_breach", + "serial", + "sku", + "smbios_tag", + "status", + "visible_alarm" + ], + "sectionRelativeRepoPath": "chassis_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fchassis_info.yml&value=name%3A%20chassis_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/chocolatey_packages", + "title": "chocolatey_packages", + "htmlId": "table--chocolateypackages--a948b45942", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "chocolatey_packages", + "author", + "license", + "name", + "path", + "summary", + "version" + ], + "sectionRelativeRepoPath": "chocolatey_packages", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fchocolatey_packages.yml&value=name%3A%20chocolatey_packages%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/chrome_extension_content_scripts", + "title": "chrome_extension_content_scripts", + "htmlId": "table--chromeextensioncontentscripts--90dfc8f7b0", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "chrome_extension_content_scripts", + "browser_type", + "identifier", + "match", + "path", + "profile_path", + "referenced", + "script", + "uid", + "version" + ], + "sectionRelativeRepoPath": "chrome_extension_content_scripts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/chrome_extension_content_scripts.yml" + }, + { + "url": "/tables/chrome_extensions", + "title": "chrome_extensions", + "htmlId": "table--chromeextensions--0b832601b4", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "chrome_extensions", + "author", + "browser_type", + "current_locale", + "default_locale", + "description", + "from_webstore", + "identifier", + "install_time", + "install_timestamp", + "key", + "manifest_hash", + "manifest_json", + "name", + "optional_permissions", + "optional_permissions_json", + "path", + "permissions", + "permissions_json", + "persistent", + "profile", + "profile_path", + "referenced", + "referenced_identifier", + "state", + "uid", + "update_url", + "version" + ], + "sectionRelativeRepoPath": "chrome_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/chrome_extensions.yml" + }, + { + "url": "/tables/cis_audit", + "title": "cis_audit", + "htmlId": "table--cisaudit--021dcf9746", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "cis_audit", + "item", + "value" + ], + "sectionRelativeRepoPath": "cis_audit", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cis_audit.yml" + }, + { + "url": "/tables/connected_displays", + "title": "connected_displays", + "htmlId": "table--connecteddisplays--f57653bc5b", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "connected_displays", + "ambient_brightness_enabled", + "connection_type", + "display_id", + "display_type", + "main", + "manufactured_week", + "manufactured_year", + "mirror", + "name", + "online", + "pixels", + "product_id", + "resolution", + "rotation", + "serial_number", + "vendor_id" + ], + "sectionRelativeRepoPath": "connected_displays", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fconnected_displays.yml&value=name%3A%20connected_displays%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/connectivity", + "title": "connectivity", + "htmlId": "table--connectivity--9bd961f435", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "connectivity", + "disconnected", + "ipv4_internet", + "ipv4_local_network", + "ipv4_no_traffic", + "ipv4_subnet", + "ipv6_internet", + "ipv6_local_network", + "ipv6_no_traffic", + "ipv6_subnet" + ], + "sectionRelativeRepoPath": "connectivity", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fconnectivity.yml&value=name%3A%20connectivity%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/corestorage_logical_volume_families", + "title": "corestorage_logical_volume_families", + "htmlId": "table--corestoragelogicalvolumefamilies--c844b6943f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "corestorage_logical_volume_families", + "EncryptionStatus", + "EncryptionType", + "HasVisibleUsers", + "HasVolumeKey", + "IsAcceptingNewUsers", + "IsFullySecure", + "MayHaveEncryptedEvents", + "RequiresPasswordUnlock", + "UUID", + "vg_FreeSpace", + "vg_FusionDrive", + "vg_Name", + "vg_Sequence", + "vg_Size", + "vg_Sparse", + "vg_Status", + "vg_UUID", + "vg_Version" + ], + "sectionRelativeRepoPath": "corestorage_logical_volume_families", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/corestorage_logical_volume_families.yml" + }, + { + "url": "/tables/corestorage_logical_volumes", + "title": "corestorage_logical_volumes", + "htmlId": "table--corestoragelogicalvolumes--b32c10c6c2", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "corestorage_logical_volumes", + "ContentHint", + "ConversionState", + "ConverstionProgressPercent", + "DesignatedPhysicalVolume", + "DesignatedPhysicalVolumeIdentifier", + "Identifier", + "Name", + "Sequence", + "Size", + "Status", + "UUID", + "Version", + "VolumeName", + "lvf_EncryptionStatus", + "lvf_EncryptionType", + "lvf_HasVisibleUsers", + "lvf_HasVolumeKey", + "lvf_IsAcceptingNewUsers", + "lvf_IsFullySecure", + "lvf_MayHaveEncryptedEvents", + "lvf_RequiresPasswordUnlock", + "lvf_UUID", + "vg_FreeSpace", + "vg_FusionDrive", + "vg_Name", + "vg_Sequence", + "vg_Size", + "vg_Sparse", + "vg_Status", + "vg_UUID", + "vg_Version" + ], + "sectionRelativeRepoPath": "corestorage_logical_volumes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/corestorage_logical_volumes.yml" + }, + { + "url": "/tables/cpu_info", + "title": "cpu_info", + "htmlId": "table--cpuinfo--aa3c0cfb0c", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "cpu_info", + "address_width", + "availability", + "cpu_status", + "current_clock_speed", + "device_id", + "load_percentage", + "logical_processors", + "manufacturer", + "max_clock_speed", + "model", + "number_of_cores", + "number_of_efficiency_cores", + "number_of_performance_cores", + "processor_type", + "socket_designation" + ], + "sectionRelativeRepoPath": "cpu_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cpu_info.yml" + }, + { + "url": "/tables/cpu_time", + "title": "cpu_time", + "htmlId": "table--cputime--8f68637ee3", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "cpu_time", + "core", + "guest", + "guest_nice", + "idle", + "iowait", + "irq", + "nice", + "softirq", + "steal", + "system", + "user" + ], + "sectionRelativeRepoPath": "cpu_time", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cpu_time.yml" + }, + { + "url": "/tables/cpuid", + "title": "cpuid", + "htmlId": "table--cpuid--68704a46e7", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "cpuid", + "feature", + "input_eax", + "output_bit", + "output_register", + "value" + ], + "sectionRelativeRepoPath": "cpuid", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cpuid.yml" + }, + { + "url": "/tables/crashes", + "title": "crashes", + "htmlId": "table--crashes--6bccea7c2f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "crashes", + "crash_path", + "crashed_thread", + "datetime", + "exception_codes", + "exception_notes", + "exception_type", + "identifier", + "parent", + "path", + "pid", + "registers", + "responsible", + "stack_trace", + "type", + "uid", + "version" + ], + "sectionRelativeRepoPath": "crashes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/crashes.yml" + }, + { + "url": "/tables/crontab", + "title": "crontab", + "htmlId": "table--crontab--a8fe1b5316", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "crontab", + "command", + "day_of_month", + "day_of_week", + "event", + "hour", + "minute", + "month", + "path", + "pid_with_namespace" + ], + "sectionRelativeRepoPath": "crontab", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/crontab.yml" + }, + { + "url": "/tables/cryptoinfo", + "title": "cryptoinfo", + "htmlId": "table--cryptoinfo--5e90627c08", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "cryptoinfo", + "fullkey", + "key", + "parent", + "passphrase", + "path", + "query", + "value" + ], + "sectionRelativeRepoPath": "cryptoinfo", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cryptoinfo.yml" + }, + { + "url": "/tables/cryptsetup_status", + "title": "cryptsetup_status", + "htmlId": "table--cryptsetupstatus--3aa1264a26", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "cryptsetup_status", + "fullkey", + "key", + "name", + "parent", + "query", + "value" + ], + "sectionRelativeRepoPath": "cryptsetup_status", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cryptsetup_status.yml" + }, + { + "url": "/tables/csrutil_info", + "title": "csrutil_info", + "htmlId": "table--csrutilinfo--959d823b8e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "csrutil_info", + "ssv_enabled" + ], + "sectionRelativeRepoPath": "csrutil_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/csrutil_info.yml" + }, + { + "url": "/tables/cups_destinations", + "title": "cups_destinations", + "htmlId": "table--cupsdestinations--8ccb3721f2", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "cups_destinations", + "name", + "option_name", + "option_value" + ], + "sectionRelativeRepoPath": "cups_destinations", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cups_destinations.yml" + }, + { + "url": "/tables/cups_jobs", + "title": "cups_jobs", + "htmlId": "table--cupsjobs--3268465efb", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "cups_jobs", + "completed_time", + "creation_time", + "destination", + "format", + "processing_time", + "size", + "title", + "user" + ], + "sectionRelativeRepoPath": "cups_jobs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/cups_jobs.yml" + }, + { + "url": "/tables/curl", + "title": "curl", + "htmlId": "table--curl--2ab03fa14d", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "curl", + "bytes", + "method", + "response_code", + "result", + "round_trip_time", + "url", + "user_agent" + ], + "sectionRelativeRepoPath": "curl", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/curl.yml" + }, + { + "url": "/tables/curl_certificate", + "title": "curl_certificate", + "htmlId": "table--curlcertificate--6d52c798b0", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "curl_certificate", + "authority_key_identifier", + "basic_constraint", + "common_name", + "dump_certificate", + "extended_key_usage", + "has_expired", + "hostname", + "info_access", + "issuer_alternative_names", + "issuer_common_name", + "issuer_organization", + "issuer_organization_unit", + "key_usage", + "name_constraints", + "organization", + "organization_unit", + "pem", + "policies", + "policy_constraints", + "policy_mappings", + "serial_number", + "sha1_fingerprint", + "sha256_fingerprint", + "signature", + "signature_algorithm", + "subject_alternative_names", + "subject_info_access", + "subject_key_identifier", + "timeout", + "valid_from", + "valid_to", + "version" + ], + "sectionRelativeRepoPath": "curl_certificate", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/curl_certificate.yml" + }, + { + "url": "/tables/deb_packages", + "title": "deb_packages", + "htmlId": "table--debpackages--f9f4ca0355", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "deb_packages", + "admindir", + "arch", + "maintainer", + "mount_namespace_id", + "name", + "pid_with_namespace", + "priority", + "revision", + "section", + "size", + "source", + "status", + "version" + ], + "sectionRelativeRepoPath": "deb_packages", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/deb_packages.yml" + }, + { + "url": "/tables/default_environment", + "title": "default_environment", + "htmlId": "table--defaultenvironment--ccbaea6671", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "default_environment", + "expand", + "value", + "variable" + ], + "sectionRelativeRepoPath": "default_environment", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdefault_environment.yml&value=name%3A%20default_environment%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/device_file", + "title": "device_file", + "htmlId": "table--devicefile--e5267d9f3e", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "device_file", + "atime", + "block_size", + "ctime", + "device", + "filename", + "gid", + "hard_links", + "inode", + "mode", + "mtime", + "partition", + "path", + "size", + "type", + "uid" + ], + "sectionRelativeRepoPath": "device_file", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdevice_file.yml&value=name%3A%20device_file%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/device_firmware", + "title": "device_firmware", + "htmlId": "table--devicefirmware--ab4ba7dd63", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "device_firmware", + "device", + "type", + "version" + ], + "sectionRelativeRepoPath": "device_firmware", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/device_firmware.yml" + }, + { + "url": "/tables/device_hash", + "title": "device_hash", + "htmlId": "table--devicehash--c839a630b0", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "device_hash", + "device", + "inode", + "md5", + "partition", + "sha1", + "sha256" + ], + "sectionRelativeRepoPath": "device_hash", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdevice_hash.yml&value=name%3A%20device_hash%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/device_partitions", + "title": "device_partitions", + "htmlId": "table--devicepartitions--3489019e85", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "device_partitions", + "blocks", + "blocks_size", + "device", + "flags", + "inodes", + "label", + "offset", + "partition", + "type" + ], + "sectionRelativeRepoPath": "device_partitions", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdevice_partitions.yml&value=name%3A%20device_partitions%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/disk_encryption", + "title": "disk_encryption", + "htmlId": "table--diskencryption--26d5b55253", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "disk_encryption", + "encrypted", + "encryption_status", + "filevault_status", + "name", + "type", + "uid", + "user_uuid", + "uuid" + ], + "sectionRelativeRepoPath": "disk_encryption", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/disk_encryption.yml" + }, + { + "url": "/tables/disk_events", + "title": "disk_events", + "htmlId": "table--diskevents--737534006f", + "evented": true, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "disk_events", + "action", + "checksum", + "content", + "device", + "eid", + "ejectable", + "filesystem", + "media_name", + "mountable", + "name", + "path", + "size", + "time", + "uuid", + "vendor", + "writable" + ], + "sectionRelativeRepoPath": "disk_events", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/disk_events.yml" + }, + { + "url": "/tables/disk_info", + "title": "disk_info", + "htmlId": "table--diskinfo--e7393d4e29", + "evented": false, + "platforms": [ + "windows", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "disk_info", + "description", + "disk_index", + "disk_size", + "hardware_model", + "id", + "manufacturer", + "name", + "partitions", + "pnp_device_id", + "serial", + "type" + ], + "sectionRelativeRepoPath": "disk_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/disk_info.yml" + }, + { + "url": "/tables/dns_cache", + "title": "dns_cache", + "htmlId": "table--dnscache--dc0e67fdcf", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "dns_cache", + "flags", + "name", + "type" + ], + "sectionRelativeRepoPath": "dns_cache", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/dns_cache.yml" + }, + { + "url": "/tables/dns_resolvers", + "title": "dns_resolvers", + "htmlId": "table--dnsresolvers--2c8fb31e5d", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "dns_resolvers", + "address", + "id", + "netmask", + "options", + "pid_with_namespace", + "type" + ], + "sectionRelativeRepoPath": "dns_resolvers", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/dns_resolvers.yml" + }, + { + "url": "/tables/docker_container_envs", + "title": "docker_container_envs", + "htmlId": "table--dockercontainerenvs--3f92fabef8", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_envs", + "id", + "key", + "value" + ], + "sectionRelativeRepoPath": "docker_container_envs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_container_envs.yml" + }, + { + "url": "/tables/docker_container_fs_changes", + "title": "docker_container_fs_changes", + "htmlId": "table--dockercontainerfschanges--e8a13529f8", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_fs_changes", + "change_type", + "id", + "path" + ], + "sectionRelativeRepoPath": "docker_container_fs_changes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_container_fs_changes.yml&value=name%3A%20docker_container_fs_changes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_container_labels", + "title": "docker_container_labels", + "htmlId": "table--dockercontainerlabels--525f815a85", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_labels", + "id", + "key", + "value" + ], + "sectionRelativeRepoPath": "docker_container_labels", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_container_labels.yml" + }, + { + "url": "/tables/docker_container_mounts", + "title": "docker_container_mounts", + "htmlId": "table--dockercontainermounts--feffa9b278", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_mounts", + "destination", + "driver", + "id", + "mode", + "name", + "propagation", + "rw", + "source", + "type" + ], + "sectionRelativeRepoPath": "docker_container_mounts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_container_mounts.yml" + }, + { + "url": "/tables/docker_container_networks", + "title": "docker_container_networks", + "htmlId": "table--dockercontainernetworks--7482838a3b", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_networks", + "endpoint_id", + "gateway", + "id", + "ip_address", + "ip_prefix_len", + "ipv6_address", + "ipv6_gateway", + "ipv6_prefix_len", + "mac_address", + "name", + "network_id" + ], + "sectionRelativeRepoPath": "docker_container_networks", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_container_networks.yml" + }, + { + "url": "/tables/docker_container_ports", + "title": "docker_container_ports", + "htmlId": "table--dockercontainerports--8fa613bed0", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_ports", + "host_ip", + "host_port", + "id", + "port", + "type" + ], + "sectionRelativeRepoPath": "docker_container_ports", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_container_ports.yml" + }, + { + "url": "/tables/docker_container_processes", + "title": "docker_container_processes", + "htmlId": "table--dockercontainerprocesses--3790b40e9b", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_processes", + "cmdline", + "cpu", + "egid", + "euid", + "gid", + "id", + "mem", + "name", + "nice", + "parent", + "pgroup", + "pid", + "resident_size", + "sgid", + "start_time", + "state", + "suid", + "threads", + "time", + "total_size", + "uid", + "user", + "wired_size" + ], + "sectionRelativeRepoPath": "docker_container_processes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_container_processes.yml&value=name%3A%20docker_container_processes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_container_stats", + "title": "docker_container_stats", + "htmlId": "table--dockercontainerstats--55f8d1f434", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_container_stats", + "cpu_kernelmode_usage", + "cpu_total_usage", + "cpu_usermode_usage", + "disk_read", + "disk_write", + "id", + "interval", + "memory_cached", + "memory_limit", + "memory_max_usage", + "memory_usage", + "name", + "network_rx_bytes", + "network_tx_bytes", + "num_procs", + "online_cpus", + "pids", + "pre_cpu_kernelmode_usage", + "pre_cpu_total_usage", + "pre_cpu_usermode_usage", + "pre_online_cpus", + "pre_system_cpu_usage", + "preread", + "read", + "system_cpu_usage" + ], + "sectionRelativeRepoPath": "docker_container_stats", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_container_stats.yml&value=name%3A%20docker_container_stats%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_containers", + "title": "docker_containers", + "htmlId": "table--dockercontainers--e586f60cb7", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_containers", + "cgroup_namespace", + "command", + "config_entrypoint", + "created", + "env_variables", + "finished_at", + "id", + "image", + "image_id", + "ipc_namespace", + "mnt_namespace", + "name", + "net_namespace", + "path", + "pid", + "pid_namespace", + "privileged", + "readonly_rootfs", + "security_options", + "started_at", + "state", + "status", + "user_namespace", + "uts_namespace" + ], + "sectionRelativeRepoPath": "docker_containers", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_containers.yml" + }, + { + "url": "/tables/docker_image_history", + "title": "docker_image_history", + "htmlId": "table--dockerimagehistory--77b04426fe", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_image_history", + "comment", + "created", + "created_by", + "id", + "size", + "tags" + ], + "sectionRelativeRepoPath": "docker_image_history", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_image_history.yml&value=name%3A%20docker_image_history%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_image_labels", + "title": "docker_image_labels", + "htmlId": "table--dockerimagelabels--14e0871386", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_image_labels", + "id", + "key", + "value" + ], + "sectionRelativeRepoPath": "docker_image_labels", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_image_labels.yml&value=name%3A%20docker_image_labels%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_image_layers", + "title": "docker_image_layers", + "htmlId": "table--dockerimagelayers--91693c4e4c", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_image_layers", + "id", + "layer_id", + "layer_order" + ], + "sectionRelativeRepoPath": "docker_image_layers", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_image_layers.yml&value=name%3A%20docker_image_layers%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_images", + "title": "docker_images", + "htmlId": "table--dockerimages--6819d40071", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_images", + "created", + "id", + "size_bytes", + "tags" + ], + "sectionRelativeRepoPath": "docker_images", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_images.yml" + }, + { + "url": "/tables/docker_info", + "title": "docker_info", + "htmlId": "table--dockerinfo--2f30b285cd", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_info", + "architecture", + "bridge_nf_ip6tables", + "bridge_nf_iptables", + "cgroup_driver", + "containers", + "containers_paused", + "containers_running", + "containers_stopped", + "cpu_cfs_period", + "cpu_cfs_quota", + "cpu_set", + "cpu_shares", + "cpus", + "http_proxy", + "https_proxy", + "id", + "images", + "ipv4_forwarding", + "kernel_memory", + "kernel_version", + "logging_driver", + "memory", + "memory_limit", + "name", + "no_proxy", + "oom_kill_disable", + "os", + "os_type", + "root_dir", + "server_version", + "storage_driver", + "swap_limit" + ], + "sectionRelativeRepoPath": "docker_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_info.yml&value=name%3A%20docker_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_network_labels", + "title": "docker_network_labels", + "htmlId": "table--dockernetworklabels--1f827dc474", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_network_labels", + "id", + "key", + "value" + ], + "sectionRelativeRepoPath": "docker_network_labels", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_network_labels.yml&value=name%3A%20docker_network_labels%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_networks", + "title": "docker_networks", + "htmlId": "table--dockernetworks--2ae40ea518", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_networks", + "created", + "driver", + "enable_ipv6", + "gateway", + "id", + "name", + "subnet" + ], + "sectionRelativeRepoPath": "docker_networks", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_networks.yml&value=name%3A%20docker_networks%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_version", + "title": "docker_version", + "htmlId": "table--dockerversion--d5c8b11df6", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_version", + "api_version", + "arch", + "build_time", + "git_commit", + "go_version", + "kernel_version", + "min_api_version", + "os", + "version" + ], + "sectionRelativeRepoPath": "docker_version", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_version.yml&value=name%3A%20docker_version%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_volume_labels", + "title": "docker_volume_labels", + "htmlId": "table--dockervolumelabels--45adc74ba3", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_volume_labels", + "key", + "name", + "value" + ], + "sectionRelativeRepoPath": "docker_volume_labels", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdocker_volume_labels.yml&value=name%3A%20docker_volume_labels%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/docker_volumes", + "title": "docker_volumes", + "htmlId": "table--dockervolumes--cee95f6b90", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "docker_volumes", + "driver", + "mount_point", + "name", + "type" + ], + "sectionRelativeRepoPath": "docker_volumes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/docker_volumes.yml" + }, + { + "url": "/tables/drivers", + "title": "drivers", + "htmlId": "table--drivers--58290d489f", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "drivers", + "class", + "date", + "description", + "device_id", + "device_name", + "driver_key", + "image", + "inf", + "manufacturer", + "provider", + "service", + "service_key", + "signed", + "version" + ], + "sectionRelativeRepoPath": "drivers", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fdrivers.yml&value=name%3A%20drivers%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/dscl", + "title": "dscl", + "htmlId": "table--dscl--54e7060384", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "dscl", + "command", + "key", + "path", + "value" + ], + "sectionRelativeRepoPath": "dscl", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/dscl.yml" + }, + { + "url": "/tables/ec2_instance_metadata", + "title": "ec2_instance_metadata", + "htmlId": "table--ec2instancemetadata--8b1828d8f6", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ec2_instance_metadata", + "account_id", + "ami_id", + "architecture", + "availability_zone", + "iam_arn", + "instance_id", + "instance_type", + "local_hostname", + "local_ipv4", + "mac", + "region", + "reservation_id", + "security_groups", + "ssh_public_key" + ], + "sectionRelativeRepoPath": "ec2_instance_metadata", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fec2_instance_metadata.yml&value=name%3A%20ec2_instance_metadata%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/ec2_instance_tags", + "title": "ec2_instance_tags", + "htmlId": "table--ec2instancetags--450384158f", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ec2_instance_tags", + "instance_id", + "key", + "value" + ], + "sectionRelativeRepoPath": "ec2_instance_tags", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fec2_instance_tags.yml&value=name%3A%20ec2_instance_tags%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/es_process_events", + "title": "es_process_events", + "htmlId": "table--esprocessevents--d79d694750", + "evented": true, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "es_process_events", + "cdhash", + "child_pid", + "cmdline", + "cmdline_count", + "codesigning_flags", + "cwd", + "egid", + "eid", + "env", + "env_count", + "euid", + "event_type", + "exit_code", + "gid", + "global_seq_num", + "original_parent", + "parent", + "path", + "pid", + "platform_binary", + "seq_num", + "signing_id", + "team_id", + "time", + "uid", + "username", + "version" + ], + "sectionRelativeRepoPath": "es_process_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fes_process_events.yml&value=name%3A%20es_process_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/es_process_file_events", + "title": "es_process_file_events", + "htmlId": "table--esprocessfileevents--e28968a0e8", + "evented": true, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "es_process_file_events", + "dest_filename", + "eid", + "event_type", + "filename", + "global_seq_num", + "parent", + "path", + "pid", + "seq_num", + "time", + "version" + ], + "sectionRelativeRepoPath": "es_process_file_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fes_process_file_events.yml&value=name%3A%20es_process_file_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/etc_hosts", + "title": "etc_hosts", + "htmlId": "table--etchosts--a56205b3f9", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "etc_hosts", + "address", + "hostnames", + "pid_with_namespace" + ], + "sectionRelativeRepoPath": "etc_hosts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/etc_hosts.yml" + }, + { + "url": "/tables/etc_protocols", + "title": "etc_protocols", + "htmlId": "table--etcprotocols--b5ffb257d1", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "etc_protocols", + "alias", + "comment", + "name", + "number" + ], + "sectionRelativeRepoPath": "etc_protocols", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fetc_protocols.yml&value=name%3A%20etc_protocols%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/etc_services", + "title": "etc_services", + "htmlId": "table--etcservices--454572c18c", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "etc_services", + "aliases", + "comment", + "name", + "port", + "protocol" + ], + "sectionRelativeRepoPath": "etc_services", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/etc_services.yml" + }, + { + "url": "/tables/event_taps", + "title": "event_taps", + "htmlId": "table--eventtaps--b2afde9ecc", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "event_taps", + "enabled", + "event_tap_id", + "event_tapped", + "process_being_tapped", + "tapping_process" + ], + "sectionRelativeRepoPath": "event_taps", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/event_taps.yml" + }, + { + "url": "/tables/extended_attributes", + "title": "extended_attributes", + "htmlId": "table--extendedattributes--9dea030217", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "extended_attributes", + "base64", + "directory", + "key", + "path", + "value" + ], + "sectionRelativeRepoPath": "extended_attributes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fextended_attributes.yml&value=name%3A%20extended_attributes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/falcon_kernel_check", + "title": "falcon_kernel_check", + "htmlId": "table--falconkernelcheck--5479232641", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "falcon_kernel_check", + "kernel", + "sensor_version", + "supported" + ], + "sectionRelativeRepoPath": "falcon_kernel_check", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/falcon_kernel_check.yml" + }, + { + "url": "/tables/falconctl_options", + "title": "falconctl_options", + "htmlId": "table--falconctloptions--7106491b65", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "falconctl_options", + "options" + ], + "sectionRelativeRepoPath": "falconctl_options", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/falconctl_options.yml" + }, + { + "url": "/tables/fan_speed_sensors", + "title": "fan_speed_sensors", + "htmlId": "table--fanspeedsensors--32417c8bf6", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "fan_speed_sensors", + "actual", + "fan", + "max", + "min", + "name", + "target" + ], + "sectionRelativeRepoPath": "fan_speed_sensors", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Ffan_speed_sensors.yml&value=name%3A%20fan_speed_sensors%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/file", + "title": "file", + "htmlId": "table--file--5f21761417", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "file", + "atime", + "attributes", + "block_size", + "bsd_flags", + "btime", + "ctime", + "device", + "directory", + "file_id", + "file_version", + "filename", + "gid", + "hard_links", + "inode", + "mode", + "mount_namespace_id", + "mtime", + "original_filename", + "path", + "pid_with_namespace", + "product_version", + "shortcut_comment", + "shortcut_run", + "shortcut_start_in", + "shortcut_target_location", + "shortcut_target_path", + "shortcut_target_type", + "size", + "symlink", + "type", + "uid", + "volume_serial" + ], + "sectionRelativeRepoPath": "file", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/file.yml" + }, + { + "url": "/tables/file_events", + "title": "file_events", + "htmlId": "table--fileevents--7d5b0a2d3e", + "evented": true, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "file_events", + "action", + "atime", + "category", + "ctime", + "eid", + "gid", + "hashed", + "inode", + "md5", + "mode", + "mtime", + "sha1", + "sha256", + "size", + "target_path", + "time", + "transaction_id", + "uid" + ], + "sectionRelativeRepoPath": "file_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Ffile_events.yml&value=name%3A%20file_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/file_lines", + "title": "file_lines", + "htmlId": "table--filelines--66f7e5497f", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "file_lines", + "line", + "path" + ], + "sectionRelativeRepoPath": "file_lines", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/file_lines.yml" + }, + { + "url": "/tables/filevault_prk", + "title": "filevault_prk", + "htmlId": "table--filevaultprk--4327326014", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "filevault_prk", + "base64_encrypted" + ], + "sectionRelativeRepoPath": "filevault_prk", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/filevault_prk.yml" + }, + { + "url": "/tables/filevault_status", + "title": "filevault_status", + "htmlId": "table--filevaultstatus--808666eddb", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "filevault_status", + "status" + ], + "sectionRelativeRepoPath": "filevault_status", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/filevault_status.yml" + }, + { + "url": "/tables/filevault_users", + "title": "filevault_users", + "htmlId": "table--filevaultusers--283a958213", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "filevault_users", + "username", + "uuid" + ], + "sectionRelativeRepoPath": "filevault_users", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/filevault_users.yml" + }, + { + "url": "/tables/find_cmd", + "title": "find_cmd", + "htmlId": "table--findcmd--6d09c7cd5f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "find_cmd", + "directory", + "path", + "perm", + "type" + ], + "sectionRelativeRepoPath": "find_cmd", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/find_cmd.yml" + }, + { + "url": "/tables/firefox_addons", + "title": "firefox_addons", + "htmlId": "table--firefoxaddons--9eabc39fea", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "firefox_addons", + "active", + "autoupdate", + "creator", + "description", + "disabled", + "identifier", + "location", + "name", + "path", + "source_url", + "type", + "uid", + "version", + "visible" + ], + "sectionRelativeRepoPath": "firefox_addons", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/firefox_addons.yml" + }, + { + "url": "/tables/firefox_preferences", + "title": "firefox_preferences", + "htmlId": "table--firefoxpreferences--2366a56fa1", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "firefox_preferences", + "fullkey", + "key", + "parent", + "path", + "query", + "value" + ], + "sectionRelativeRepoPath": "firefox_preferences", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/firefox_preferences.yml" + }, + { + "url": "/tables/firmware_eficheck_integrity_check", + "title": "firmware_eficheck_integrity_check", + "htmlId": "table--firmwareeficheckintegritycheck--88da320790", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "firmware_eficheck_integrity_check", + "chip", + "output" + ], + "sectionRelativeRepoPath": "firmware_eficheck_integrity_check", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/firmware_eficheck_integrity_check.yml" + }, + { + "url": "/tables/firmwarepasswd", + "title": "firmwarepasswd", + "htmlId": "table--firmwarepasswd--34c47d2dc2", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "firmwarepasswd", + "mode", + "option_roms_allowed", + "password_enabled" + ], + "sectionRelativeRepoPath": "firmwarepasswd", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/firmwarepasswd.yml" + }, + { + "url": "/tables/fleetd_logs", + "title": "fleetd_logs", + "htmlId": "table--fleetdlogs--04f95fb2e5", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "fleetd_logs", + "error", + "level", + "message", + "payload", + "time" + ], + "sectionRelativeRepoPath": "fleetd_logs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/fleetd_logs.yml" + }, + { + "url": "/tables/gatekeeper", + "title": "gatekeeper", + "htmlId": "table--gatekeeper--c48826d081", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "gatekeeper", + "assessments_enabled", + "dev_id_enabled", + "opaque_version", + "version" + ], + "sectionRelativeRepoPath": "gatekeeper", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/gatekeeper.yml" + }, + { + "url": "/tables/gatekeeper_approved_apps", + "title": "gatekeeper_approved_apps", + "htmlId": "table--gatekeeperapprovedapps--ccb2041adc", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "gatekeeper_approved_apps", + "ctime", + "mtime", + "path", + "requirement" + ], + "sectionRelativeRepoPath": "gatekeeper_approved_apps", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fgatekeeper_approved_apps.yml&value=name%3A%20gatekeeper_approved_apps%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/geolocation", + "title": "geolocation", + "htmlId": "table--geolocation--0338bc3ba9", + "evented": false, + "platforms": [ + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "geolocation", + "city", + "country", + "ip", + "region" + ], + "sectionRelativeRepoPath": "geolocation", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/geolocation.yml" + }, + { + "url": "/tables/google_chrome_profiles", + "title": "google_chrome_profiles", + "htmlId": "table--googlechromeprofiles--bc22157648", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "google_chrome_profiles", + "email", + "ephemeral", + "name", + "username" + ], + "sectionRelativeRepoPath": "google_chrome_profiles", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/google_chrome_profiles.yml" + }, + { + "url": "/tables/groups", + "title": "groups", + "htmlId": "table--groups--05fec1d6ce", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "groups", + "comment", + "gid", + "gid_signed", + "group_sid", + "groupname", + "is_hidden", + "pid_with_namespace" + ], + "sectionRelativeRepoPath": "groups", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/groups.yml" + }, + { + "url": "/tables/hardware_events", + "title": "hardware_events", + "htmlId": "table--hardwareevents--f7cce3883a", + "evented": true, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "hardware_events", + "action", + "driver", + "eid", + "model", + "model_id", + "path", + "revision", + "serial", + "time", + "type", + "vendor", + "vendor_id" + ], + "sectionRelativeRepoPath": "hardware_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fhardware_events.yml&value=name%3A%20hardware_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/hash", + "title": "hash", + "htmlId": "table--hash--c08ce91512", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "hash", + "directory", + "md5", + "mount_namespace_id", + "path", + "pid_with_namespace", + "sha1", + "sha256" + ], + "sectionRelativeRepoPath": "hash", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/hash.yml" + }, + { + "url": "/tables/homebrew_packages", + "title": "homebrew_packages", + "htmlId": "table--homebrewpackages--9c26173ba7", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "homebrew_packages", + "name", + "path", + "prefix", + "type", + "version" + ], + "sectionRelativeRepoPath": "homebrew_packages", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/homebrew_packages.yml" + }, + { + "url": "/tables/hvci_status", + "title": "hvci_status", + "htmlId": "table--hvcistatus--46a3ee08e5", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "hvci_status", + "code_integrity_policy_enforcement_status", + "instance_identifier", + "umci_policy_status", + "vbs_status", + "version" + ], + "sectionRelativeRepoPath": "hvci_status", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fhvci_status.yml&value=name%3A%20hvci_status%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/ibridge_info", + "title": "ibridge_info", + "htmlId": "table--ibridgeinfo--38f5f5d7eb", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "ibridge_info", + "boot_uuid", + "coprocessor_version", + "firmware_version", + "unique_chip_id" + ], + "sectionRelativeRepoPath": "ibridge_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fibridge_info.yml&value=name%3A%20ibridge_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/icloud_private_relay", + "title": "icloud_private_relay", + "htmlId": "table--icloudprivaterelay--7cbb9c575c", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "icloud_private_relay", + "status" + ], + "sectionRelativeRepoPath": "icloud_private_relay", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/icloud_private_relay.yml" + }, + { + "url": "/tables/ie_extensions", + "title": "ie_extensions", + "htmlId": "table--ieextensions--412b814817", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ie_extensions", + "name", + "path", + "registry_path", + "version" + ], + "sectionRelativeRepoPath": "ie_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ie_extensions.yml" + }, + { + "url": "/tables/intel_me_info", + "title": "intel_me_info", + "htmlId": "table--intelmeinfo--fd5eb9626f", + "evented": false, + "platforms": [ + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "intel_me_info", + "version" + ], + "sectionRelativeRepoPath": "intel_me_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fintel_me_info.yml&value=name%3A%20intel_me_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/interface_addresses", + "title": "interface_addresses", + "htmlId": "table--interfaceaddresses--4163068693", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "interface_addresses", + "address", + "broadcast", + "friendly_name", + "interface", + "mask", + "point_to_point", + "type" + ], + "sectionRelativeRepoPath": "interface_addresses", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/interface_addresses.yml" + }, + { + "url": "/tables/interface_details", + "title": "interface_details", + "htmlId": "table--interfacedetails--c8234f77ad", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "interface_details", + "collisions", + "connection_id", + "connection_status", + "description", + "dhcp_enabled", + "dhcp_lease_expires", + "dhcp_lease_obtained", + "dhcp_server", + "dns_domain", + "dns_domain_suffix_search_order", + "dns_host_name", + "dns_server_search_order", + "enabled", + "flags", + "friendly_name", + "ibytes", + "idrops", + "ierrors", + "interface", + "ipackets", + "last_change", + "link_speed", + "mac", + "manufacturer", + "metric", + "mtu", + "obytes", + "odrops", + "oerrors", + "opackets", + "pci_slot", + "physical_adapter", + "service", + "speed", + "type" + ], + "sectionRelativeRepoPath": "interface_details", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/interface_details.yml" + }, + { + "url": "/tables/interface_ipv6", + "title": "interface_ipv6", + "htmlId": "table--interfaceipv6--48a78776ae", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "interface_ipv6", + "forwarding_enabled", + "hop_limit", + "interface", + "redirect_accept", + "rtadv_accept" + ], + "sectionRelativeRepoPath": "interface_ipv6", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/interface_ipv6.yml" + }, + { + "url": "/tables/iokit_devicetree", + "title": "iokit_devicetree", + "htmlId": "table--iokitdevicetree--475d23de81", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "iokit_devicetree", + "busy_state", + "class", + "depth", + "device_path", + "id", + "name", + "parent", + "retain_count", + "service" + ], + "sectionRelativeRepoPath": "iokit_devicetree", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/iokit_devicetree.yml" + }, + { + "url": "/tables/iokit_registry", + "title": "iokit_registry", + "htmlId": "table--iokitregistry--213523c85d", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "iokit_registry", + "busy_state", + "class", + "depth", + "id", + "name", + "parent", + "retain_count" + ], + "sectionRelativeRepoPath": "iokit_registry", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/iokit_registry.yml" + }, + { + "url": "/tables/ioreg", + "title": "ioreg", + "htmlId": "table--ioreg--64934c5b2c", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "ioreg", + "c", + "d", + "fullkey", + "k", + "key", + "n", + "p", + "parent", + "query", + "r", + "value" + ], + "sectionRelativeRepoPath": "ioreg", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ioreg.yml" + }, + { + "url": "/tables/iptables", + "title": "iptables", + "htmlId": "table--iptables--73fe23ccfd", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "iptables", + "bytes", + "chain", + "dst_ip", + "dst_mask", + "dst_port", + "filter_name", + "iniface", + "iniface_mask", + "match", + "outiface", + "outiface_mask", + "packets", + "policy", + "protocol", + "src_ip", + "src_mask", + "src_port", + "target" + ], + "sectionRelativeRepoPath": "iptables", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/iptables.yml" + }, + { + "url": "/tables/kernel_extensions", + "title": "kernel_extensions", + "htmlId": "table--kernelextensions--015ed33cfc", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "kernel_extensions", + "idx", + "linked_against", + "name", + "path", + "refs", + "size", + "version" + ], + "sectionRelativeRepoPath": "kernel_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/kernel_extensions.yml" + }, + { + "url": "/tables/kernel_info", + "title": "kernel_info", + "htmlId": "table--kernelinfo--e02ab4d886", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "kernel_info", + "arguments", + "device", + "path", + "version" + ], + "sectionRelativeRepoPath": "kernel_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/kernel_info.yml" + }, + { + "url": "/tables/kernel_keys", + "title": "kernel_keys", + "htmlId": "table--kernelkeys--c3a84244c8", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "kernel_keys", + "description", + "flags", + "gid", + "permissions", + "serial_number", + "timeout", + "type", + "uid", + "usage" + ], + "sectionRelativeRepoPath": "kernel_keys", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fkernel_keys.yml&value=name%3A%20kernel_keys%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/kernel_modules", + "title": "kernel_modules", + "htmlId": "table--kernelmodules--c9051ad100", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "kernel_modules", + "address", + "name", + "size", + "status", + "used_by" + ], + "sectionRelativeRepoPath": "kernel_modules", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fkernel_modules.yml&value=name%3A%20kernel_modules%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/kernel_panics", + "title": "kernel_panics", + "htmlId": "table--kernelpanics--c6cb2cce6e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "kernel_panics", + "dependencies", + "frame_backtrace", + "kernel_version", + "last_loaded", + "last_unloaded", + "module_backtrace", + "name", + "os_version", + "path", + "registers", + "system_model", + "time", + "uptime" + ], + "sectionRelativeRepoPath": "kernel_panics", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/kernel_panics.yml" + }, + { + "url": "/tables/keychain_acls", + "title": "keychain_acls", + "htmlId": "table--keychainacls--e46564a1f0", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "keychain_acls", + "authorizations", + "description", + "keychain_path", + "label", + "path" + ], + "sectionRelativeRepoPath": "keychain_acls", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/keychain_acls.yml" + }, + { + "url": "/tables/keychain_items", + "title": "keychain_items", + "htmlId": "table--keychainitems--ceb19aa966", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "keychain_items", + "account", + "comment", + "created", + "description", + "label", + "modified", + "path", + "pk_hash", + "type" + ], + "sectionRelativeRepoPath": "keychain_items", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/keychain_items.yml" + }, + { + "url": "/tables/known_hosts", + "title": "known_hosts", + "htmlId": "table--knownhosts--2c508bc3c8", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "known_hosts", + "key", + "key_file", + "uid" + ], + "sectionRelativeRepoPath": "known_hosts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/known_hosts.yml" + }, + { + "url": "/tables/kva_speculative_info", + "title": "kva_speculative_info", + "htmlId": "table--kvaspeculativeinfo--aa9ff39cc2", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "kva_speculative_info", + "bp_microcode_disabled", + "bp_mitigations", + "bp_system_pol_disabled", + "cpu_pred_cmd_supported", + "cpu_spec_ctrl_supported", + "ibrs_support_enabled", + "kva_shadow_enabled", + "kva_shadow_inv_pcid", + "kva_shadow_pcid", + "kva_shadow_user_global", + "stibp_support_enabled" + ], + "sectionRelativeRepoPath": "kva_speculative_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fkva_speculative_info.yml&value=name%3A%20kva_speculative_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/last", + "title": "last", + "htmlId": "table--last--81b773b51e", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "last", + "host", + "pid", + "time", + "tty", + "type", + "type_name", + "username" + ], + "sectionRelativeRepoPath": "last", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/last.yml" + }, + { + "url": "/tables/launchd", + "title": "launchd", + "htmlId": "table--launchd--e309e31831", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "launchd", + "disabled", + "groupname", + "inetd_compatibility", + "keep_alive", + "label", + "name", + "on_demand", + "path", + "process_type", + "program", + "program_arguments", + "queue_directories", + "root_directory", + "run_at_load", + "start_interval", + "start_on_mount", + "stderr_path", + "stdout_path", + "username", + "watch_paths", + "working_directory" + ], + "sectionRelativeRepoPath": "launchd", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/launchd.yml" + }, + { + "url": "/tables/launchd_overrides", + "title": "launchd_overrides", + "htmlId": "table--launchdoverrides--89410cb367", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "launchd_overrides", + "key", + "label", + "path", + "uid", + "value" + ], + "sectionRelativeRepoPath": "launchd_overrides", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flaunchd_overrides.yml&value=name%3A%20launchd_overrides%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/listening_ports", + "title": "listening_ports", + "htmlId": "table--listeningports--de6bf76ec3", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "listening_ports", + "address", + "family", + "fd", + "net_namespace", + "path", + "pid", + "port", + "protocol", + "socket" + ], + "sectionRelativeRepoPath": "listening_ports", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/listening_ports.yml" + }, + { + "url": "/tables/load_average", + "title": "load_average", + "htmlId": "table--loadaverage--f5f080e140", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "load_average", + "average", + "period" + ], + "sectionRelativeRepoPath": "load_average", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/load_average.yml" + }, + { + "url": "/tables/location_services", + "title": "location_services", + "htmlId": "table--locationservices--f22473f4be", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "location_services", + "enabled" + ], + "sectionRelativeRepoPath": "location_services", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/location_services.yml" + }, + { + "url": "/tables/logged_in_users", + "title": "logged_in_users", + "htmlId": "table--loggedinusers--bd140b0e93", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "logged_in_users", + "host", + "pid", + "registry_hive", + "sid", + "time", + "tty", + "type", + "user" + ], + "sectionRelativeRepoPath": "logged_in_users", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/logged_in_users.yml" + }, + { + "url": "/tables/logical_drives", + "title": "logical_drives", + "htmlId": "table--logicaldrives--e69b777f6c", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "logical_drives", + "boot_partition", + "description", + "device_id", + "file_system", + "free_space", + "size", + "type" + ], + "sectionRelativeRepoPath": "logical_drives", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flogical_drives.yml&value=name%3A%20logical_drives%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/logon_sessions", + "title": "logon_sessions", + "htmlId": "table--logonsessions--54d10b59e8", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "logon_sessions", + "authentication_package", + "dns_domain_name", + "home_directory", + "home_directory_drive", + "logon_domain", + "logon_id", + "logon_script", + "logon_server", + "logon_sid", + "logon_time", + "logon_type", + "profile_path", + "session_id", + "upn", + "user" + ], + "sectionRelativeRepoPath": "logon_sessions", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flogon_sessions.yml&value=name%3A%20logon_sessions%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_certificates", + "title": "lxd_certificates", + "htmlId": "table--lxdcertificates--06e045fa14", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_certificates", + "certificate", + "fingerprint", + "name", + "type" + ], + "sectionRelativeRepoPath": "lxd_certificates", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_certificates.yml&value=name%3A%20lxd_certificates%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_cluster", + "title": "lxd_cluster", + "htmlId": "table--lxdcluster--a8491b6203", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_cluster", + "enabled", + "member_config_description", + "member_config_entity", + "member_config_key", + "member_config_name", + "member_config_value", + "server_name" + ], + "sectionRelativeRepoPath": "lxd_cluster", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_cluster.yml&value=name%3A%20lxd_cluster%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_cluster_members", + "title": "lxd_cluster_members", + "htmlId": "table--lxdclustermembers--7d6e6837d2", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_cluster_members", + "database", + "message", + "server_name", + "status", + "url" + ], + "sectionRelativeRepoPath": "lxd_cluster_members", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_cluster_members.yml&value=name%3A%20lxd_cluster_members%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_images", + "title": "lxd_images", + "htmlId": "table--lxdimages--55db6fdd97", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_images", + "aliases", + "architecture", + "auto_update", + "cached", + "created_at", + "description", + "expires_at", + "filename", + "id", + "last_used_at", + "os", + "public", + "release", + "size", + "update_source_alias", + "update_source_certificate", + "update_source_protocol", + "update_source_server", + "uploaded_at" + ], + "sectionRelativeRepoPath": "lxd_images", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_images.yml&value=name%3A%20lxd_images%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_instance_config", + "title": "lxd_instance_config", + "htmlId": "table--lxdinstanceconfig--54469816ca", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_instance_config", + "key", + "name", + "value" + ], + "sectionRelativeRepoPath": "lxd_instance_config", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_instance_config.yml&value=name%3A%20lxd_instance_config%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_instance_devices", + "title": "lxd_instance_devices", + "htmlId": "table--lxdinstancedevices--f28caba867", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_instance_devices", + "device", + "device_type", + "key", + "name", + "value" + ], + "sectionRelativeRepoPath": "lxd_instance_devices", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_instance_devices.yml&value=name%3A%20lxd_instance_devices%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_instances", + "title": "lxd_instances", + "htmlId": "table--lxdinstances--77d953ad3e", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_instances", + "architecture", + "base_image", + "created_at", + "description", + "ephemeral", + "name", + "os", + "pid", + "processes", + "stateful", + "status" + ], + "sectionRelativeRepoPath": "lxd_instances", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_instances.yml&value=name%3A%20lxd_instances%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_networks", + "title": "lxd_networks", + "htmlId": "table--lxdnetworks--7dd5f10782", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_networks", + "bytes_received", + "bytes_sent", + "hwaddr", + "ipv4_address", + "ipv6_address", + "managed", + "mtu", + "name", + "packets_received", + "packets_sent", + "state", + "type", + "used_by" + ], + "sectionRelativeRepoPath": "lxd_networks", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_networks.yml&value=name%3A%20lxd_networks%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/lxd_storage_pools", + "title": "lxd_storage_pools", + "htmlId": "table--lxdstoragepools--950b575e61", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "lxd_storage_pools", + "driver", + "inodes_total", + "inodes_used", + "name", + "size", + "source", + "space_total", + "space_used" + ], + "sectionRelativeRepoPath": "lxd_storage_pools", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Flxd_storage_pools.yml&value=name%3A%20lxd_storage_pools%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/macadmins_unified_log", + "title": "macadmins_unified_log", + "htmlId": "table--macadminsunifiedlog--e036df9e57", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "macadmins_unified_log", + "activity_identifier", + "boot_uuid", + "category", + "event_message", + "event_type", + "format_string", + "log_level", + "parent_activity_identifier", + "process_id", + "process_image_path", + "sender_image_path", + "sender_image_uuid", + "sender_program_counter", + "subsystem", + "thread_id", + "timestamp", + "trace_id" + ], + "sectionRelativeRepoPath": "macadmins_unified_log", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/macadmins_unified_log.yml" + }, + { + "url": "/tables/macos_profiles", + "title": "macos_profiles", + "htmlId": "table--macosprofiles--cae047dfff", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "macos_profiles", + "description", + "display_name", + "identifier", + "install_date", + "organization", + "type", + "uuid", + "verification_state" + ], + "sectionRelativeRepoPath": "macos_profiles", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/macos_profiles.yml" + }, + { + "url": "/tables/macos_rsr", + "title": "macos_rsr", + "htmlId": "table--macosrsr--9c9ef590fd", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "macos_rsr", + "full_macos_version", + "macos_version", + "rsr_supported", + "rsr_version" + ], + "sectionRelativeRepoPath": "macos_rsr", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/macos_rsr.yml" + }, + { + "url": "/tables/magic", + "title": "magic", + "htmlId": "table--magic--2b54571c80", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "magic", + "data", + "magic_db_files", + "mime_encoding", + "mime_type", + "path" + ], + "sectionRelativeRepoPath": "magic", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmagic.yml&value=name%3A%20magic%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/managed_policies", + "title": "managed_policies", + "htmlId": "table--managedpolicies--494a329dfb", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "managed_policies", + "domain", + "manual", + "name", + "username", + "uuid", + "value" + ], + "sectionRelativeRepoPath": "managed_policies", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/managed_policies.yml" + }, + { + "url": "/tables/md_devices", + "title": "md_devices", + "htmlId": "table--mddevices--cc18ebf22a", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "md_devices", + "active_disks", + "bitmap_chunk_size", + "bitmap_external_file", + "bitmap_on_mem", + "check_array_finish", + "check_array_progress", + "check_array_speed", + "chunk_size", + "device_name", + "failed_disks", + "nr_raid_disks", + "other", + "raid_disks", + "raid_level", + "recovery_finish", + "recovery_progress", + "recovery_speed", + "reshape_finish", + "reshape_progress", + "reshape_speed", + "resync_finish", + "resync_progress", + "resync_speed", + "size", + "spare_disks", + "status", + "superblock_state", + "superblock_update_time", + "superblock_version", + "unused_devices", + "working_disks" + ], + "sectionRelativeRepoPath": "md_devices", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmd_devices.yml&value=name%3A%20md_devices%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/md_drives", + "title": "md_drives", + "htmlId": "table--mddrives--f529358f7d", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "md_drives", + "drive_name", + "md_device_name", + "slot", + "state" + ], + "sectionRelativeRepoPath": "md_drives", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmd_drives.yml&value=name%3A%20md_drives%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/md_personalities", + "title": "md_personalities", + "htmlId": "table--mdpersonalities--6234b42367", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "md_personalities", + "name" + ], + "sectionRelativeRepoPath": "md_personalities", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmd_personalities.yml&value=name%3A%20md_personalities%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/mdfind", + "title": "mdfind", + "htmlId": "table--mdfind--2061531fab", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "mdfind", + "path", + "query" + ], + "sectionRelativeRepoPath": "mdfind", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmdfind.yml&value=name%3A%20mdfind%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/mdls", + "title": "mdls", + "htmlId": "table--mdls--8826cff54e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "mdls", + "key", + "path", + "value", + "valuetype" + ], + "sectionRelativeRepoPath": "mdls", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/mdls.yml" + }, + { + "url": "/tables/mdm", + "title": "mdm", + "htmlId": "table--mdm--4e74952c0b", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "mdm", + "access_rights", + "checkin_url", + "dep_capable", + "enrolled", + "has_scep_payload", + "identity_certificate_uuid", + "install_date", + "installed_from_dep", + "payload_identifier", + "server_url", + "sign_message", + "topic", + "user_approved" + ], + "sectionRelativeRepoPath": "mdm", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/mdm.yml" + }, + { + "url": "/tables/mdm_bridge", + "title": "mdm_bridge", + "htmlId": "table--mdmbridge--6dff726888", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "mdm_bridge", + "enrolled_user", + "enrollment_status", + "mdm_command_input", + "mdm_command_output", + "raw_mdm_command_output" + ], + "sectionRelativeRepoPath": "mdm_bridge", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/mdm_bridge.yml" + }, + { + "url": "/tables/memory_array_mapped_addresses", + "title": "memory_array_mapped_addresses", + "htmlId": "table--memoryarraymappedaddresses--6f656395f7", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_array_mapped_addresses", + "ending_address", + "handle", + "memory_array_handle", + "partition_width", + "starting_address" + ], + "sectionRelativeRepoPath": "memory_array_mapped_addresses", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_array_mapped_addresses.yml&value=name%3A%20memory_array_mapped_addresses%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_arrays", + "title": "memory_arrays", + "htmlId": "table--memoryarrays--abd1487b4b", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_arrays", + "handle", + "location", + "max_capacity", + "memory_error_correction", + "memory_error_info_handle", + "number_memory_devices", + "use" + ], + "sectionRelativeRepoPath": "memory_arrays", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_arrays.yml&value=name%3A%20memory_arrays%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_device_mapped_addresses", + "title": "memory_device_mapped_addresses", + "htmlId": "table--memorydevicemappedaddresses--21aa4bee51", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_device_mapped_addresses", + "ending_address", + "handle", + "interleave_data_depth", + "interleave_position", + "memory_array_mapped_address_handle", + "memory_device_handle", + "partition_row_position", + "starting_address" + ], + "sectionRelativeRepoPath": "memory_device_mapped_addresses", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_device_mapped_addresses.yml&value=name%3A%20memory_device_mapped_addresses%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_devices", + "title": "memory_devices", + "htmlId": "table--memorydevices--8e8226757f", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "memory_devices", + "array_handle", + "asset_tag", + "bank_locator", + "configured_clock_speed", + "configured_voltage", + "data_width", + "device_locator", + "form_factor", + "handle", + "manufacturer", + "max_speed", + "max_voltage", + "memory_type", + "memory_type_details", + "min_voltage", + "part_number", + "serial_number", + "set", + "size", + "total_width" + ], + "sectionRelativeRepoPath": "memory_devices", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_devices.yml&value=name%3A%20memory_devices%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_error_info", + "title": "memory_error_info", + "htmlId": "table--memoryerrorinfo--0e04980533", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_error_info", + "device_error_address", + "error_granularity", + "error_operation", + "error_resolution", + "error_type", + "handle", + "memory_array_error_address", + "vendor_syndrome" + ], + "sectionRelativeRepoPath": "memory_error_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_error_info.yml&value=name%3A%20memory_error_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_info", + "title": "memory_info", + "htmlId": "table--memoryinfo--84feac1e17", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_info", + "active", + "buffers", + "cached", + "inactive", + "memory_available", + "memory_free", + "memory_total", + "swap_cached", + "swap_free", + "swap_total" + ], + "sectionRelativeRepoPath": "memory_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_info.yml&value=name%3A%20memory_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/memory_map", + "title": "memory_map", + "htmlId": "table--memorymap--dbdfd30e2f", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "memory_map", + "end", + "name", + "start" + ], + "sectionRelativeRepoPath": "memory_map", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmemory_map.yml&value=name%3A%20memory_map%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/mounts", + "title": "mounts", + "htmlId": "table--mounts--9bd193b227", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "mounts", + "blocks", + "blocks_available", + "blocks_free", + "blocks_size", + "device", + "device_alias", + "flags", + "inodes", + "inodes_free", + "path", + "type" + ], + "sectionRelativeRepoPath": "mounts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/mounts.yml" + }, + { + "url": "/tables/msr", + "title": "msr", + "htmlId": "table--msr--ff9484332b", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "msr", + "feature_control", + "perf_ctl", + "perf_status", + "platform_info", + "processor_number", + "rapl_energy_status", + "rapl_power_limit", + "rapl_power_units", + "turbo_disabled", + "turbo_ratio_limit" + ], + "sectionRelativeRepoPath": "msr", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fmsr.yml&value=name%3A%20msr%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/munki_info", + "title": "munki_info", + "htmlId": "table--munkiinfo--2e4b112369", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "munki_info", + "console_user", + "end_time", + "errors", + "manifest_name", + "problem_installs", + "start_time", + "success", + "version", + "warnings" + ], + "sectionRelativeRepoPath": "munki_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/munki_info.yml" + }, + { + "url": "/tables/munki_installs", + "title": "munki_installs", + "htmlId": "table--munkiinstalls--b403c42531", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "munki_installs", + "end_time", + "installed", + "installed_version", + "name" + ], + "sectionRelativeRepoPath": "munki_installs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/munki_installs.yml" + }, + { + "url": "/tables/network_interfaces", + "title": "network_interfaces", + "htmlId": "table--networkinterfaces--ea6f795816", + "evented": false, + "platforms": [ + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "network_interfaces", + "ipv4", + "ipv6", + "mac" + ], + "sectionRelativeRepoPath": "network_interfaces", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/network_interfaces.yml" + }, + { + "url": "/tables/nfs_shares", + "title": "nfs_shares", + "htmlId": "table--nfsshares--b4f614d51e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "nfs_shares", + "options", + "readonly", + "share" + ], + "sectionRelativeRepoPath": "nfs_shares", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/nfs_shares.yml" + }, + { + "url": "/tables/npm_packages", + "title": "npm_packages", + "htmlId": "table--npmpackages--b2a26bbba0", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "npm_packages", + "author", + "description", + "directory", + "homepage", + "license", + "mount_namespace_id", + "name", + "path", + "pid_with_namespace", + "version" + ], + "sectionRelativeRepoPath": "npm_packages", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/npm_packages.yml" + }, + { + "url": "/tables/ntdomains", + "title": "ntdomains", + "htmlId": "table--ntdomains--57ef982364", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ntdomains", + "client_site_name", + "dc_site_name", + "dns_forest_name", + "domain_controller_address", + "domain_controller_name", + "domain_name", + "name", + "status" + ], + "sectionRelativeRepoPath": "ntdomains", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ntdomains.yml" + }, + { + "url": "/tables/ntfs_acl_permissions", + "title": "ntfs_acl_permissions", + "htmlId": "table--ntfsaclpermissions--2d66c6c45e", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ntfs_acl_permissions", + "access", + "inherited_from", + "path", + "principal", + "type" + ], + "sectionRelativeRepoPath": "ntfs_acl_permissions", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fntfs_acl_permissions.yml&value=name%3A%20ntfs_acl_permissions%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/ntfs_journal_events", + "title": "ntfs_journal_events", + "htmlId": "table--ntfsjournalevents--2369d84275", + "evented": true, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ntfs_journal_events", + "action", + "category", + "drive_letter", + "eid", + "file_attributes", + "node_ref_number", + "old_path", + "parent_ref_number", + "partial", + "path", + "record_timestamp", + "record_usn", + "time" + ], + "sectionRelativeRepoPath": "ntfs_journal_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fntfs_journal_events.yml&value=name%3A%20ntfs_journal_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/nvram", + "title": "nvram", + "htmlId": "table--nvram--450a99f968", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "nvram", + "name", + "type", + "value" + ], + "sectionRelativeRepoPath": "nvram", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/nvram.yml" + }, + { + "url": "/tables/nvram_info", + "title": "nvram_info", + "htmlId": "table--nvraminfo--a99cb280af", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "nvram_info", + "amfi_enabled" + ], + "sectionRelativeRepoPath": "nvram_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/nvram_info.yml" + }, + { + "url": "/tables/oem_strings", + "title": "oem_strings", + "htmlId": "table--oemstrings--89f170ddda", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "oem_strings", + "handle", + "number", + "value" + ], + "sectionRelativeRepoPath": "oem_strings", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Foem_strings.yml&value=name%3A%20oem_strings%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/office_mru", + "title": "office_mru", + "htmlId": "table--officemru--11e1929c70", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "office_mru", + "application", + "last_opened_time", + "path", + "sid", + "version" + ], + "sectionRelativeRepoPath": "office_mru", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Foffice_mru.yml&value=name%3A%20office_mru%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/orbit_info", + "title": "orbit_info", + "htmlId": "table--orbitinfo--98fca7c408", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "orbit_info", + "desktop_channel", + "desktop_version", + "device_auth_token", + "enrolled", + "last_recorded_error", + "orbit_channel", + "osqueryd_channel", + "scripts_enabled", + "uptime", + "version" + ], + "sectionRelativeRepoPath": "orbit_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/orbit_info.yml" + }, + { + "url": "/tables/os_version", + "title": "os_version", + "htmlId": "table--osversion--95451301c8", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "os_version", + "arch", + "build", + "codename", + "extra", + "install_date", + "major", + "minor", + "mount_namespace_id", + "name", + "patch", + "pid_with_namespace", + "platform", + "platform_like", + "revision", + "version" + ], + "sectionRelativeRepoPath": "os_version", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/os_version.yml" + }, + { + "url": "/tables/osquery_events", + "title": "osquery_events", + "htmlId": "table--osqueryevents--3bff81a1b8", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_events", + "active", + "events", + "name", + "publisher", + "refreshes", + "subscriptions", + "type" + ], + "sectionRelativeRepoPath": "osquery_events", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_events.yml" + }, + { + "url": "/tables/osquery_extensions", + "title": "osquery_extensions", + "htmlId": "table--osqueryextensions--56dea82216", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_extensions", + "name", + "path", + "sdk_version", + "type", + "uuid", + "version" + ], + "sectionRelativeRepoPath": "osquery_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_extensions.yml" + }, + { + "url": "/tables/osquery_flags", + "title": "osquery_flags", + "htmlId": "table--osqueryflags--27972ebab6", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_flags", + "default_value", + "description", + "name", + "shell_only", + "type", + "value" + ], + "sectionRelativeRepoPath": "osquery_flags", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_flags.yml" + }, + { + "url": "/tables/osquery_info", + "title": "osquery_info", + "htmlId": "table--osqueryinfo--99ebd3222b", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_info", + "build_distro", + "build_platform", + "config_hash", + "config_valid", + "extensions", + "instance_id", + "pid", + "platform_mask", + "start_time", + "uuid", + "version", + "watcher" + ], + "sectionRelativeRepoPath": "osquery_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_info.yml" + }, + { + "url": "/tables/osquery_packs", + "title": "osquery_packs", + "htmlId": "table--osquerypacks--c2f0293ed5", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_packs", + "active", + "discovery_cache_hits", + "discovery_executions", + "name", + "platform", + "shard", + "version" + ], + "sectionRelativeRepoPath": "osquery_packs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_packs.yml" + }, + { + "url": "/tables/osquery_registry", + "title": "osquery_registry", + "htmlId": "table--osqueryregistry--723b93f998", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_registry", + "active", + "internal", + "name", + "owner_uuid", + "registry" + ], + "sectionRelativeRepoPath": "osquery_registry", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_registry.yml" + }, + { + "url": "/tables/osquery_schedule", + "title": "osquery_schedule", + "htmlId": "table--osqueryschedule--81eadaf536", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "osquery_schedule", + "average_memory", + "denylisted", + "executions", + "interval", + "last_executed", + "last_memory", + "last_system_time", + "last_user_time", + "last_wall_time_ms", + "name", + "output_size", + "query", + "system_time", + "user_time", + "wall_time", + "wall_time_ms" + ], + "sectionRelativeRepoPath": "osquery_schedule", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/osquery_schedule.yml" + }, + { + "url": "/tables/package_bom", + "title": "package_bom", + "htmlId": "table--packagebom--8182ed768f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "package_bom", + "filepath", + "gid", + "mode", + "modified_time", + "path", + "size", + "uid" + ], + "sectionRelativeRepoPath": "package_bom", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/package_bom.yml" + }, + { + "url": "/tables/package_install_history", + "title": "package_install_history", + "htmlId": "table--packageinstallhistory--988f999553", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "package_install_history", + "content_type", + "name", + "package_id", + "source", + "time", + "version" + ], + "sectionRelativeRepoPath": "package_install_history", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/package_install_history.yml" + }, + { + "url": "/tables/package_receipts", + "title": "package_receipts", + "htmlId": "table--packagereceipts--4d830b5b2d", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "package_receipts", + "install_time", + "installer_name", + "location", + "package_filename", + "package_id", + "path", + "version" + ], + "sectionRelativeRepoPath": "package_receipts", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/package_receipts.yml" + }, + { + "url": "/tables/parse_ini", + "title": "parse_ini", + "htmlId": "table--parseini--4de2377a57", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "parse_ini", + "fullkey", + "key", + "parent", + "path", + "value" + ], + "sectionRelativeRepoPath": "parse_ini", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/parse_ini.yml" + }, + { + "url": "/tables/parse_json", + "title": "parse_json", + "htmlId": "table--parsejson--c3c9947479", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "parse_json", + "fullkey", + "key", + "parent", + "path", + "value" + ], + "sectionRelativeRepoPath": "parse_json", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/parse_json.yml" + }, + { + "url": "/tables/parse_jsonl", + "title": "parse_jsonl", + "htmlId": "table--parsejsonl--b71d789467", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "parse_jsonl", + "fullkey", + "key", + "parent", + "path", + "value" + ], + "sectionRelativeRepoPath": "parse_jsonl", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/parse_jsonl.yml" + }, + { + "url": "/tables/parse_xml", + "title": "parse_xml", + "htmlId": "table--parsexml--15ed589727", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "parse_xml", + "fullkey", + "key", + "parent", + "path", + "value" + ], + "sectionRelativeRepoPath": "parse_xml", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/parse_xml.yml" + }, + { + "url": "/tables/password_policy", + "title": "password_policy", + "htmlId": "table--passwordpolicy--9a2e1051b8", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "password_policy", + "policy_content", + "policy_description", + "policy_identifier", + "uid" + ], + "sectionRelativeRepoPath": "password_policy", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/password_policy.yml" + }, + { + "url": "/tables/patches", + "title": "patches", + "htmlId": "table--patches--b3f61813f5", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "patches", + "caption", + "csname", + "description", + "fix_comments", + "hotfix_id", + "install_date", + "installed_by", + "installed_on" + ], + "sectionRelativeRepoPath": "patches", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/patches.yml" + }, + { + "url": "/tables/pci_devices", + "title": "pci_devices", + "htmlId": "table--pcidevices--b00adf6d59", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "pci_devices", + "driver", + "model", + "model_id", + "pci_class", + "pci_class_id", + "pci_slot", + "pci_subclass", + "pci_subclass_id", + "subsystem_model", + "subsystem_model_id", + "subsystem_vendor", + "subsystem_vendor_id", + "vendor", + "vendor_id" + ], + "sectionRelativeRepoPath": "pci_devices", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/pci_devices.yml" + }, + { + "url": "/tables/physical_disk_performance", + "title": "physical_disk_performance", + "htmlId": "table--physicaldiskperformance--21ffb96328", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "physical_disk_performance", + "avg_disk_bytes_per_read", + "avg_disk_bytes_per_write", + "avg_disk_read_queue_length", + "avg_disk_sec_per_read", + "avg_disk_sec_per_write", + "avg_disk_write_queue_length", + "current_disk_queue_length", + "name", + "percent_disk_read_time", + "percent_disk_time", + "percent_disk_write_time", + "percent_idle_time" + ], + "sectionRelativeRepoPath": "physical_disk_performance", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fphysical_disk_performance.yml&value=name%3A%20physical_disk_performance%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/pipes", + "title": "pipes", + "htmlId": "table--pipes--6c348a0bda", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "pipes", + "flags", + "instances", + "max_instances", + "name", + "pid" + ], + "sectionRelativeRepoPath": "pipes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/pipes.yml" + }, + { + "url": "/tables/platform_info", + "title": "platform_info", + "htmlId": "table--platforminfo--606b0b07f8", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "platform_info", + "address", + "date", + "extra", + "firmware_type", + "revision", + "size", + "vendor", + "version", + "volume_size" + ], + "sectionRelativeRepoPath": "platform_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/platform_info.yml" + }, + { + "url": "/tables/plist", + "title": "plist", + "htmlId": "table--plist--10bd270ccc", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "plist", + "key", + "path", + "subkey", + "value" + ], + "sectionRelativeRepoPath": "plist", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/plist.yml" + }, + { + "url": "/tables/pmset", + "title": "pmset", + "htmlId": "table--pmset--5f7c05dca3", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "pmset", + "getting", + "json_result" + ], + "sectionRelativeRepoPath": "pmset", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/pmset.yml" + }, + { + "url": "/tables/portage_keywords", + "title": "portage_keywords", + "htmlId": "table--portagekeywords--16048373f7", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "portage_keywords", + "keyword", + "mask", + "package", + "unmask", + "version" + ], + "sectionRelativeRepoPath": "portage_keywords", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fportage_keywords.yml&value=name%3A%20portage_keywords%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/portage_packages", + "title": "portage_packages", + "htmlId": "table--portagepackages--af336b6b49", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "portage_packages", + "build_time", + "eapi", + "package", + "repository", + "size", + "slot", + "version", + "world" + ], + "sectionRelativeRepoPath": "portage_packages", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fportage_packages.yml&value=name%3A%20portage_packages%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/portage_use", + "title": "portage_use", + "htmlId": "table--portageuse--61384aa618", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "portage_use", + "package", + "use", + "version" + ], + "sectionRelativeRepoPath": "portage_use", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fportage_use.yml&value=name%3A%20portage_use%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/power_sensors", + "title": "power_sensors", + "htmlId": "table--powersensors--27bd8387f6", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "power_sensors", + "category", + "key", + "name", + "value" + ], + "sectionRelativeRepoPath": "power_sensors", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/power_sensors.yml" + }, + { + "url": "/tables/powershell_events", + "title": "powershell_events", + "htmlId": "table--powershellevents--728605e870", + "evented": true, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "powershell_events", + "cosine_similarity", + "datetime", + "script_block_count", + "script_block_id", + "script_name", + "script_path", + "script_text", + "time" + ], + "sectionRelativeRepoPath": "powershell_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fpowershell_events.yml&value=name%3A%20powershell_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/preferences", + "title": "preferences", + "htmlId": "table--preferences--96fcf226b3", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "preferences", + "domain", + "forced", + "host", + "key", + "subkey", + "username", + "value" + ], + "sectionRelativeRepoPath": "preferences", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/preferences.yml" + }, + { + "url": "/tables/prefetch", + "title": "prefetch", + "htmlId": "table--prefetch--8592ee7112", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "prefetch", + "accessed_directories", + "accessed_directories_count", + "accessed_files", + "accessed_files_count", + "filename", + "hash", + "last_run_time", + "other_run_times", + "path", + "run_count", + "size", + "volume_creation", + "volume_serial" + ], + "sectionRelativeRepoPath": "prefetch", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fprefetch.yml&value=name%3A%20prefetch%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/privacy_preferences", + "title": "privacy_preferences", + "htmlId": "table--privacypreferences--927ea3e9b3", + "evented": false, + "platforms": [ + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "privacy_preferences", + "ad_measurement_enabled", + "autofill_address_enabled", + "autofill_credit_card_enabled", + "autofill_enabled", + "do_not_track_enabled", + "fledge_enabled", + "hyperlink_auditing_enabled", + "network_prediction_enabled", + "privacy_sandbox_enabled", + "protected_content_enabled", + "referrers_enabled", + "safe_browsing_enabled", + "safe_browsing_extended_reporting_enabled", + "save_passwords_enabled", + "search_suggest_enabled", + "spelling_service_enabled", + "third_party_cookies_allowed", + "topics_enabled", + "translation_service_enabled", + "web_rtc_ip_handling_policy" + ], + "sectionRelativeRepoPath": "privacy_preferences", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/privacy_preferences.yml" + }, + { + "url": "/tables/process_envs", + "title": "process_envs", + "htmlId": "table--processenvs--586b20fc53", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_envs", + "key", + "pid", + "value" + ], + "sectionRelativeRepoPath": "process_envs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_envs.yml" + }, + { + "url": "/tables/process_etw_events", + "title": "process_etw_events", + "htmlId": "table--processetwevents--61143eacfc", + "evented": true, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "process_etw_events", + "cmdline", + "datetime", + "eid", + "exit_code", + "flags", + "header_pid", + "mandatory_label", + "parent_process_sequence_number", + "path", + "pid", + "ppid", + "process_sequence_number", + "session_id", + "time", + "time_windows", + "token_elevation_status", + "token_elevation_type", + "type", + "username" + ], + "sectionRelativeRepoPath": "process_etw_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fprocess_etw_events.yml&value=name%3A%20process_etw_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/process_events", + "title": "process_events", + "htmlId": "table--processevents--6ae8ba2267", + "evented": true, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_events", + "atime", + "auid", + "btime", + "cmdline", + "cmdline_size", + "ctime", + "cwd", + "egid", + "eid", + "env", + "env_count", + "env_size", + "euid", + "fsgid", + "fsuid", + "gid", + "mode", + "mtime", + "overflows", + "owner_gid", + "owner_uid", + "parent", + "path", + "pid", + "sgid", + "status", + "suid", + "syscall", + "time", + "uid", + "uptime" + ], + "sectionRelativeRepoPath": "process_events", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_events.yml" + }, + { + "url": "/tables/process_file_events", + "title": "process_file_events", + "htmlId": "table--processfileevents--67c363ae55", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_file_events", + "auid", + "cwd", + "dest_path", + "egid", + "eid", + "euid", + "executable", + "fsgid", + "fsuid", + "gid", + "operation", + "partial", + "path", + "pid", + "ppid", + "sgid", + "suid", + "time", + "uid", + "uptime" + ], + "sectionRelativeRepoPath": "process_file_events", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_file_events.yml" + }, + { + "url": "/tables/process_memory_map", + "title": "process_memory_map", + "htmlId": "table--processmemorymap--6bf8d10644", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "process_memory_map", + "device", + "end", + "inode", + "offset", + "path", + "permissions", + "pid", + "pseudo", + "start" + ], + "sectionRelativeRepoPath": "process_memory_map", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_memory_map.yml" + }, + { + "url": "/tables/process_namespaces", + "title": "process_namespaces", + "htmlId": "table--processnamespaces--d1156621d4", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_namespaces", + "cgroup_namespace", + "ipc_namespace", + "mnt_namespace", + "net_namespace", + "pid", + "pid_namespace", + "user_namespace", + "uts_namespace" + ], + "sectionRelativeRepoPath": "process_namespaces", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fprocess_namespaces.yml&value=name%3A%20process_namespaces%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/process_open_files", + "title": "process_open_files", + "htmlId": "table--processopenfiles--43c8c6bba0", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_open_files", + "fd", + "path", + "pid" + ], + "sectionRelativeRepoPath": "process_open_files", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_open_files.yml" + }, + { + "url": "/tables/process_open_pipes", + "title": "process_open_pipes", + "htmlId": "table--processopenpipes--0f49c83994", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "process_open_pipes", + "fd", + "inode", + "mode", + "partner_fd", + "partner_mode", + "partner_pid", + "pid", + "type" + ], + "sectionRelativeRepoPath": "process_open_pipes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fprocess_open_pipes.yml&value=name%3A%20process_open_pipes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/process_open_sockets", + "title": "process_open_sockets", + "htmlId": "table--processopensockets--9dc2c99a67", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "process_open_sockets", + "family", + "fd", + "local_address", + "local_port", + "net_namespace", + "path", + "pid", + "protocol", + "remote_address", + "remote_port", + "socket", + "state" + ], + "sectionRelativeRepoPath": "process_open_sockets", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/process_open_sockets.yml" + }, + { + "url": "/tables/processes", + "title": "processes", + "htmlId": "table--processes--3a54ed4992", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "processes", + "cgroup_path", + "cmdline", + "cpu_subtype", + "cpu_type", + "cwd", + "disk_bytes_read", + "disk_bytes_written", + "egid", + "elapsed_time", + "elevated_token", + "euid", + "gid", + "handle_count", + "name", + "nice", + "on_disk", + "parent", + "path", + "percent_processor_time", + "pgroup", + "pid", + "protection_type", + "resident_size", + "root", + "secure_process", + "sgid", + "start_time", + "state", + "suid", + "system_time", + "threads", + "total_size", + "translated", + "uid", + "upid", + "uppid", + "user_time", + "virtual_process", + "wired_size" + ], + "sectionRelativeRepoPath": "processes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/processes.yml" + }, + { + "url": "/tables/programs", + "title": "programs", + "htmlId": "table--programs--f7f76d14a9", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "programs", + "identifying_number", + "install_date", + "install_location", + "install_source", + "language", + "name", + "publisher", + "uninstall_string", + "version" + ], + "sectionRelativeRepoPath": "programs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/programs.yml" + }, + { + "url": "/tables/prometheus_metrics", + "title": "prometheus_metrics", + "htmlId": "table--prometheusmetrics--f6ce409d91", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "prometheus_metrics", + "metric_name", + "metric_value", + "target_name", + "timestamp_ms" + ], + "sectionRelativeRepoPath": "prometheus_metrics", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fprometheus_metrics.yml&value=name%3A%20prometheus_metrics%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/puppet_info", + "title": "puppet_info", + "htmlId": "table--puppetinfo--ce553a89eb", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "puppet_info", + "cached_catalog_status", + "catalog_uuid", + "code_id", + "configuration_version", + "corrective_change", + "environment", + "host", + "kind", + "master_used", + "noop", + "noop_pending", + "puppet_version", + "report_format", + "status", + "time", + "transaction_completed", + "transaction_uuid" + ], + "sectionRelativeRepoPath": "puppet_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/puppet_info.yml" + }, + { + "url": "/tables/puppet_logs", + "title": "puppet_logs", + "htmlId": "table--puppetlogs--c81bf10f91", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "puppet_logs", + "file", + "level", + "line", + "message", + "source", + "time" + ], + "sectionRelativeRepoPath": "puppet_logs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/puppet_logs.yml" + }, + { + "url": "/tables/puppet_state", + "title": "puppet_state", + "htmlId": "table--puppetstate--802f52e922", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "puppet_state", + "change_count", + "changed", + "corrective_change", + "evaluation_time", + "failed", + "file", + "line", + "out_of_sync", + "out_of_sync_count", + "resource", + "resource_type", + "skipped", + "title" + ], + "sectionRelativeRepoPath": "puppet_state", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/puppet_state.yml" + }, + { + "url": "/tables/pwd_policy", + "title": "pwd_policy", + "htmlId": "table--pwdpolicy--b862a98afa", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "pwd_policy", + "days_to_expiration", + "expires_every_n_days", + "history_depth", + "max_failed_attempts", + "min_mixed_case_characters" + ], + "sectionRelativeRepoPath": "pwd_policy", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/pwd_policy.yml" + }, + { + "url": "/tables/python_packages", + "title": "python_packages", + "htmlId": "table--pythonpackages--31ae8c2370", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "python_packages", + "author", + "directory", + "license", + "name", + "path", + "pid_with_namespace", + "summary", + "version" + ], + "sectionRelativeRepoPath": "python_packages", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/python_packages.yml" + }, + { + "url": "/tables/quicklook_cache", + "title": "quicklook_cache", + "htmlId": "table--quicklookcache--19ae561620", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "quicklook_cache", + "cache_path", + "fs_id", + "hit_count", + "icon_mode", + "inode", + "label", + "last_hit_date", + "mtime", + "path", + "rowid", + "size", + "volume_id" + ], + "sectionRelativeRepoPath": "quicklook_cache", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fquicklook_cache.yml&value=name%3A%20quicklook_cache%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/registry", + "title": "registry", + "htmlId": "table--registry--415b2b1c89", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "registry", + "data", + "key", + "mtime", + "name", + "path", + "type" + ], + "sectionRelativeRepoPath": "registry", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/registry.yml" + }, + { + "url": "/tables/routes", + "title": "routes", + "htmlId": "table--routes--ed00beba43", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "routes", + "destination", + "flags", + "gateway", + "hopcount", + "interface", + "metric", + "mtu", + "netmask", + "source", + "type" + ], + "sectionRelativeRepoPath": "routes", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/routes.yml" + }, + { + "url": "/tables/rpm_package_files", + "title": "rpm_package_files", + "htmlId": "table--rpmpackagefiles--96e530c921", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "rpm_package_files", + "groupname", + "mode", + "package", + "path", + "sha256", + "size", + "username" + ], + "sectionRelativeRepoPath": "rpm_package_files", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Frpm_package_files.yml&value=name%3A%20rpm_package_files%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/rpm_packages", + "title": "rpm_packages", + "htmlId": "table--rpmpackages--e4da8f9f41", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "rpm_packages", + "arch", + "epoch", + "install_time", + "mount_namespace_id", + "name", + "package_group", + "pid_with_namespace", + "release", + "sha1", + "size", + "source", + "vendor", + "version" + ], + "sectionRelativeRepoPath": "rpm_packages", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/rpm_packages.yml" + }, + { + "url": "/tables/running_apps", + "title": "running_apps", + "htmlId": "table--runningapps--c9443711d8", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "running_apps", + "bundle_identifier", + "is_active", + "pid" + ], + "sectionRelativeRepoPath": "running_apps", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/running_apps.yml" + }, + { + "url": "/tables/safari_extensions", + "title": "safari_extensions", + "htmlId": "table--safariextensions--75748b2d43", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "safari_extensions", + "author", + "bundle_version", + "copyright", + "description", + "developer_id", + "extension_type", + "identifier", + "name", + "path", + "sdk", + "uid", + "update_url", + "version" + ], + "sectionRelativeRepoPath": "safari_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/safari_extensions.yml" + }, + { + "url": "/tables/sandboxes", + "title": "sandboxes", + "htmlId": "table--sandboxes--c68d00ef55", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sandboxes", + "build_id", + "bundle_path", + "enabled", + "label", + "path", + "user" + ], + "sectionRelativeRepoPath": "sandboxes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fsandboxes.yml&value=name%3A%20sandboxes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/scheduled_tasks", + "title": "scheduled_tasks", + "htmlId": "table--scheduledtasks--a69b6b604d", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "scheduled_tasks", + "action", + "enabled", + "hidden", + "last_run_code", + "last_run_message", + "last_run_time", + "name", + "next_run_time", + "path", + "state" + ], + "sectionRelativeRepoPath": "scheduled_tasks", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/scheduled_tasks.yml" + }, + { + "url": "/tables/screenlock", + "title": "screenlock", + "htmlId": "table--screenlock--91a400ed71", + "evented": false, + "platforms": [ + "darwin", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "screenlock", + "enabled", + "grace_period" + ], + "sectionRelativeRepoPath": "screenlock", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/screenlock.yml" + }, + { + "url": "/tables/seccomp_events", + "title": "seccomp_events", + "htmlId": "table--seccompevents--5cf6060bd9", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "seccomp_events", + "arch", + "auid", + "code", + "comm", + "compat", + "exe", + "gid", + "ip", + "pid", + "ses", + "sig", + "syscall", + "time", + "uid", + "uptime" + ], + "sectionRelativeRepoPath": "seccomp_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fseccomp_events.yml&value=name%3A%20seccomp_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/secureboot", + "title": "secureboot", + "htmlId": "table--secureboot--299ca9c718", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "secureboot", + "description", + "kernel_extensions", + "mdm_operations", + "secure_boot", + "secure_mode", + "setup_mode" + ], + "sectionRelativeRepoPath": "secureboot", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/secureboot.yml" + }, + { + "url": "/tables/security_profile_info", + "title": "security_profile_info", + "htmlId": "table--securityprofileinfo--17121d5fa6", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "security_profile_info", + "audit_account_logon", + "audit_account_manage", + "audit_ds_access", + "audit_logon_events", + "audit_object_access", + "audit_policy_change", + "audit_privilege_use", + "audit_process_tracking", + "audit_system_events", + "clear_text_password", + "enable_admin_account", + "enable_guest_account", + "force_logoff_when_expire", + "lockout_bad_count", + "logon_to_change_password", + "lsa_anonymous_name_lookup", + "maximum_password_age", + "minimum_password_age", + "minimum_password_length", + "new_administrator_name", + "new_guest_name", + "password_complexity", + "password_history_size" + ], + "sectionRelativeRepoPath": "security_profile_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fsecurity_profile_info.yml&value=name%3A%20security_profile_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/selinux_events", + "title": "selinux_events", + "htmlId": "table--selinuxevents--cfc47c5cc9", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "selinux_events", + "eid", + "message", + "time", + "type", + "uptime" + ], + "sectionRelativeRepoPath": "selinux_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fselinux_events.yml&value=name%3A%20selinux_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/selinux_settings", + "title": "selinux_settings", + "htmlId": "table--selinuxsettings--392476076c", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "selinux_settings", + "key", + "scope", + "value" + ], + "sectionRelativeRepoPath": "selinux_settings", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fselinux_settings.yml&value=name%3A%20selinux_settings%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/services", + "title": "services", + "htmlId": "table--services--a7e374154f", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "services", + "description", + "display_name", + "module_path", + "name", + "path", + "pid", + "service_exit_code", + "service_type", + "start_type", + "status", + "user_account", + "win32_exit_code" + ], + "sectionRelativeRepoPath": "services", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fservices.yml&value=name%3A%20services%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/shadow", + "title": "shadow", + "htmlId": "table--shadow--2a5e749131", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "shadow", + "expire", + "flag", + "hash_alg", + "inactive", + "last_change", + "max", + "min", + "password_status", + "username", + "warning" + ], + "sectionRelativeRepoPath": "shadow", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fshadow.yml&value=name%3A%20shadow%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/shared_folders", + "title": "shared_folders", + "htmlId": "table--sharedfolders--edd6c29f21", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "shared_folders", + "name", + "path" + ], + "sectionRelativeRepoPath": "shared_folders", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/shared_folders.yml" + }, + { + "url": "/tables/shared_memory", + "title": "shared_memory", + "htmlId": "table--sharedmemory--4632a169c9", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "shared_memory", + "atime", + "attached", + "creator_pid", + "creator_uid", + "ctime", + "dtime", + "locked", + "owner_uid", + "permissions", + "pid", + "shmid", + "size", + "status" + ], + "sectionRelativeRepoPath": "shared_memory", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fshared_memory.yml&value=name%3A%20shared_memory%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/shared_resources", + "title": "shared_resources", + "htmlId": "table--sharedresources--1eedd340fb", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "shared_resources", + "allow_maximum", + "description", + "install_date", + "maximum_allowed", + "name", + "path", + "status", + "type", + "type_name" + ], + "sectionRelativeRepoPath": "shared_resources", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/shared_resources.yml" + }, + { + "url": "/tables/sharing_preferences", + "title": "sharing_preferences", + "htmlId": "table--sharingpreferences--435a39048e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sharing_preferences", + "bluetooth_sharing", + "content_caching", + "disc_sharing", + "file_sharing", + "internet_sharing", + "printer_sharing", + "remote_apple_events", + "remote_login", + "remote_management", + "screen_sharing" + ], + "sectionRelativeRepoPath": "sharing_preferences", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sharing_preferences.yml" + }, + { + "url": "/tables/shell_history", + "title": "shell_history", + "htmlId": "table--shellhistory--487890df4c", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "shell_history", + "command", + "history_file", + "time", + "uid" + ], + "sectionRelativeRepoPath": "shell_history", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/shell_history.yml" + }, + { + "url": "/tables/shellbags", + "title": "shellbags", + "htmlId": "table--shellbags--ea58c94fcb", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "shellbags", + "accessed_time", + "created_time", + "mft_entry", + "mft_sequence", + "modified_time", + "path", + "sid", + "source" + ], + "sectionRelativeRepoPath": "shellbags", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fshellbags.yml&value=name%3A%20shellbags%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/shimcache", + "title": "shimcache", + "htmlId": "table--shimcache--78c1808f2a", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "shimcache", + "entry", + "execution_flag", + "modified_time", + "path" + ], + "sectionRelativeRepoPath": "shimcache", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/shimcache.yml" + }, + { + "url": "/tables/signature", + "title": "signature", + "htmlId": "table--signature--651b5e1a16", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "signature", + "arch", + "authority", + "cdhash", + "hash_resources", + "identifier", + "path", + "signed", + "team_identifier" + ], + "sectionRelativeRepoPath": "signature", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/signature.yml" + }, + { + "url": "/tables/sip_config", + "title": "sip_config", + "htmlId": "table--sipconfig--72a4d07300", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sip_config", + "config_flag", + "enabled", + "enabled_nvram" + ], + "sectionRelativeRepoPath": "sip_config", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sip_config.yml" + }, + { + "url": "/tables/smbios_tables", + "title": "smbios_tables", + "htmlId": "table--smbiostables--14a3086ac5", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "smbios_tables", + "description", + "handle", + "header_size", + "md5", + "number", + "size", + "type" + ], + "sectionRelativeRepoPath": "smbios_tables", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/smbios_tables.yml" + }, + { + "url": "/tables/smc_keys", + "title": "smc_keys", + "htmlId": "table--smckeys--65a180be47", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "smc_keys", + "hidden", + "key", + "size", + "type", + "value" + ], + "sectionRelativeRepoPath": "smc_keys", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/smc_keys.yml" + }, + { + "url": "/tables/sntp_request", + "title": "sntp_request", + "htmlId": "table--sntprequest--31b3965f95", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "sntp_request", + "clock_offset_ms", + "server", + "timestamp_ms" + ], + "sectionRelativeRepoPath": "sntp_request", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sntp_request.yml" + }, + { + "url": "/tables/socket_events", + "title": "socket_events", + "htmlId": "table--socketevents--45972f7f3b", + "evented": true, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "socket_events", + "action", + "auid", + "eid", + "family", + "fd", + "local_address", + "local_port", + "path", + "pid", + "protocol", + "remote_address", + "remote_port", + "socket", + "status", + "success", + "time", + "uptime" + ], + "sectionRelativeRepoPath": "socket_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fsocket_events.yml&value=name%3A%20socket_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/sofa_security_release_info", + "title": "sofa_security_release_info", + "htmlId": "table--sofasecurityreleaseinfo--b23bdf9329", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sofa_security_release_info", + "days_since_previous_release", + "os_version", + "product_version", + "release_date", + "security_info", + "unique_cves_count", + "update_name" + ], + "sectionRelativeRepoPath": "sofa_security_release_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sofa_security_release_info.yml" + }, + { + "url": "/tables/sofa_unpatched_cves", + "title": "sofa_unpatched_cves", + "htmlId": "table--sofaunpatchedcves--680ab849b7", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sofa_unpatched_cves", + "actively_exploited", + "cve", + "os_version", + "patched_version" + ], + "sectionRelativeRepoPath": "sofa_unpatched_cves", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sofa_unpatched_cves.yml" + }, + { + "url": "/tables/software_update", + "title": "software_update", + "htmlId": "table--softwareupdate--6cb5e63ee6", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "software_update", + "software_update_required" + ], + "sectionRelativeRepoPath": "software_update", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/software_update.yml" + }, + { + "url": "/tables/ssh_configs", + "title": "ssh_configs", + "htmlId": "table--sshconfigs--084b9832a4", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ssh_configs", + "block", + "option", + "ssh_config_file", + "uid" + ], + "sectionRelativeRepoPath": "ssh_configs", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ssh_configs.yml" + }, + { + "url": "/tables/startup_items", + "title": "startup_items", + "htmlId": "table--startupitems--f212a6ad4e", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "startup_items", + "args", + "name", + "path", + "source", + "status", + "type", + "username" + ], + "sectionRelativeRepoPath": "startup_items", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/startup_items.yml" + }, + { + "url": "/tables/sudo_info", + "title": "sudo_info", + "htmlId": "table--sudoinfo--91f0750d0d", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "sudo_info", + "json_result" + ], + "sectionRelativeRepoPath": "sudo_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sudo_info.yml" + }, + { + "url": "/tables/sudoers", + "title": "sudoers", + "htmlId": "table--sudoers--53cbb8caa7", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "sudoers", + "header", + "rule_details", + "source" + ], + "sectionRelativeRepoPath": "sudoers", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/sudoers.yml" + }, + { + "url": "/tables/suid_bin", + "title": "suid_bin", + "htmlId": "table--suidbin--12efbe4810", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "suid_bin", + "groupname", + "path", + "permissions", + "pid_with_namespace", + "username" + ], + "sectionRelativeRepoPath": "suid_bin", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/suid_bin.yml" + }, + { + "url": "/tables/syslog_events", + "title": "syslog_events", + "htmlId": "table--syslogevents--cc5c3d702f", + "evented": true, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "syslog_events", + "datetime", + "eid", + "facility", + "host", + "message", + "severity", + "tag", + "time" + ], + "sectionRelativeRepoPath": "syslog_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fsyslog_events.yml&value=name%3A%20syslog_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/system_controls", + "title": "system_controls", + "htmlId": "table--systemcontrols--bc070f5bb2", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "system_controls", + "config_value", + "current_value", + "field_name", + "name", + "oid", + "subsystem", + "type" + ], + "sectionRelativeRepoPath": "system_controls", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/system_controls.yml" + }, + { + "url": "/tables/system_extensions", + "title": "system_extensions", + "htmlId": "table--systemextensions--59019bbb28", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "system_extensions", + "UUID", + "bundle_path", + "category", + "identifier", + "mdm_managed", + "path", + "state", + "team", + "version" + ], + "sectionRelativeRepoPath": "system_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/system_extensions.yml" + }, + { + "url": "/tables/system_info", + "title": "system_info", + "htmlId": "table--systeminfo--4f963da54a", + "evented": false, + "platforms": [ + "windows", + "darwin", + "linux", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "system_info", + "board_model", + "board_serial", + "board_vendor", + "board_version", + "computer_name", + "cpu_brand", + "cpu_logical_cores", + "cpu_microcode", + "cpu_physical_cores", + "cpu_sockets", + "cpu_subtype", + "cpu_type", + "hardware_model", + "hardware_serial", + "hardware_vendor", + "hardware_version", + "hostname", + "local_hostname", + "physical_memory", + "uuid" + ], + "sectionRelativeRepoPath": "system_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/system_info.yml" + }, + { + "url": "/tables/system_state", + "title": "system_state", + "htmlId": "table--systemstate--d1ce3bbb0e", + "evented": false, + "platforms": [ + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "system_state", + "idle_state" + ], + "sectionRelativeRepoPath": "system_state", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/system_state.yml" + }, + { + "url": "/tables/systemd_units", + "title": "systemd_units", + "htmlId": "table--systemdunits--cc47585fcb", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "systemd_units", + "active_state", + "description", + "following", + "fragment_path", + "id", + "job_id", + "job_path", + "job_type", + "load_state", + "object_path", + "source_path", + "sub_state", + "unit_file_state", + "user" + ], + "sectionRelativeRepoPath": "systemd_units", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fsystemd_units.yml&value=name%3A%20systemd_units%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/tcc_access", + "title": "tcc_access", + "htmlId": "table--tccaccess--103e029af3", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "tcc_access", + "auth_reason", + "auth_value", + "client", + "client_type", + "indirect_object_identifier", + "indirect_object_identifier_type", + "last_modified", + "policy_id", + "service", + "source", + "uid" + ], + "sectionRelativeRepoPath": "tcc_access", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/tcc_access.yml" + }, + { + "url": "/tables/temperature_sensors", + "title": "temperature_sensors", + "htmlId": "table--temperaturesensors--952195065c", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "temperature_sensors", + "celsius", + "fahrenheit", + "key", + "name" + ], + "sectionRelativeRepoPath": "temperature_sensors", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/temperature_sensors.yml" + }, + { + "url": "/tables/time", + "title": "time", + "htmlId": "table--time--740a172c2f", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "time", + "datetime", + "day", + "hour", + "iso_8601", + "local_timezone", + "minutes", + "month", + "seconds", + "timestamp", + "timezone", + "unix_time", + "weekday", + "win_timestamp", + "year" + ], + "sectionRelativeRepoPath": "time", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/time.yml" + }, + { + "url": "/tables/time_machine_backups", + "title": "time_machine_backups", + "htmlId": "table--timemachinebackups--6a1cb2e696", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "time_machine_backups", + "backup_date", + "destination_id" + ], + "sectionRelativeRepoPath": "time_machine_backups", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/time_machine_backups.yml" + }, + { + "url": "/tables/time_machine_destinations", + "title": "time_machine_destinations", + "htmlId": "table--timemachinedestinations--8c33b4e082", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "time_machine_destinations", + "alias", + "bytes_available", + "bytes_used", + "consistency_scan_date", + "destination_id", + "encryption", + "root_volume_uuid" + ], + "sectionRelativeRepoPath": "time_machine_destinations", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/time_machine_destinations.yml" + }, + { + "url": "/tables/tpm_info", + "title": "tpm_info", + "htmlId": "table--tpminfo--086cc37696", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "tpm_info", + "activated", + "enabled", + "manufacturer_id", + "manufacturer_name", + "manufacturer_version", + "owned", + "physical_presence_version", + "product_name", + "spec_version" + ], + "sectionRelativeRepoPath": "tpm_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Ftpm_info.yml&value=name%3A%20tpm_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/ulimit_info", + "title": "ulimit_info", + "htmlId": "table--ulimitinfo--9cff90dafb", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "ulimit_info", + "hard_limit", + "soft_limit", + "type" + ], + "sectionRelativeRepoPath": "ulimit_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/ulimit_info.yml" + }, + { + "url": "/tables/unified_log", + "title": "unified_log", + "htmlId": "table--unifiedlog--d971aaf7c9", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "unified_log", + "activity", + "category", + "level", + "max_rows", + "message", + "pid", + "predicate", + "process", + "sender", + "storage", + "subsystem", + "tid", + "timestamp" + ], + "sectionRelativeRepoPath": "unified_log", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Funified_log.yml&value=name%3A%20unified_log%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/uptime", + "title": "uptime", + "htmlId": "table--uptime--542f2cc52b", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "uptime", + "days", + "hours", + "minutes", + "seconds", + "total_seconds" + ], + "sectionRelativeRepoPath": "uptime", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/uptime.yml" + }, + { + "url": "/tables/usb_devices", + "title": "usb_devices", + "htmlId": "table--usbdevices--12892f9cf7", + "evented": false, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "usb_devices", + "class", + "model", + "model_id", + "protocol", + "removable", + "serial", + "subclass", + "usb_address", + "usb_port", + "vendor", + "vendor_id", + "version" + ], + "sectionRelativeRepoPath": "usb_devices", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/usb_devices.yml" + }, + { + "url": "/tables/user_events", + "title": "user_events", + "htmlId": "table--userevents--8aaee70de1", + "evented": true, + "platforms": [ + "darwin", + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "user_events", + "address", + "auid", + "eid", + "message", + "path", + "pid", + "terminal", + "time", + "type", + "uid", + "uptime" + ], + "sectionRelativeRepoPath": "user_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fuser_events.yml&value=name%3A%20user_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/user_groups", + "title": "user_groups", + "htmlId": "table--usergroups--03e0b1a5e7", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "user_groups", + "gid", + "uid" + ], + "sectionRelativeRepoPath": "user_groups", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fuser_groups.yml&value=name%3A%20user_groups%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/user_interaction_events", + "title": "user_interaction_events", + "htmlId": "table--userinteractionevents--ed2ac5b181", + "evented": true, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "user_interaction_events", + "time" + ], + "sectionRelativeRepoPath": "user_interaction_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fuser_interaction_events.yml&value=name%3A%20user_interaction_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/user_login_settings", + "title": "user_login_settings", + "htmlId": "table--userloginsettings--1abbdf6e57", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "user_login_settings", + "password_hint_enabled" + ], + "sectionRelativeRepoPath": "user_login_settings", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/user_login_settings.yml" + }, + { + "url": "/tables/user_ssh_keys", + "title": "user_ssh_keys", + "htmlId": "table--usersshkeys--1ba0f20456", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "user_ssh_keys", + "encrypted", + "key_type", + "path", + "pid_with_namespace", + "uid" + ], + "sectionRelativeRepoPath": "user_ssh_keys", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/user_ssh_keys.yml" + }, + { + "url": "/tables/userassist", + "title": "userassist", + "htmlId": "table--userassist--4e3bbdb293", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "userassist", + "count", + "last_execution_time", + "path", + "sid" + ], + "sectionRelativeRepoPath": "userassist", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/userassist.yml" + }, + { + "url": "/tables/users", + "title": "users", + "htmlId": "table--users--023e2862dc", + "evented": false, + "platforms": [ + "darwin", + "windows", + "linux", + "chrome" + ], + "keywordsForSyntaxHighlighting": [ + "users", + "description", + "directory", + "email", + "gid", + "gid_signed", + "is_hidden", + "pid_with_namespace", + "shell", + "type", + "uid", + "uid_signed", + "username", + "uuid" + ], + "sectionRelativeRepoPath": "users", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/users.yml" + }, + { + "url": "/tables/video_info", + "title": "video_info", + "htmlId": "table--videoinfo--bcca78a3df", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "video_info", + "color_depth", + "driver", + "driver_date", + "driver_version", + "manufacturer", + "model", + "series", + "video_mode" + ], + "sectionRelativeRepoPath": "video_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fvideo_info.yml&value=name%3A%20video_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/virtual_memory_info", + "title": "virtual_memory_info", + "htmlId": "table--virtualmemoryinfo--4c4e71449e", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "virtual_memory_info", + "active", + "anonymous", + "compressed", + "compressor", + "copy", + "decompressed", + "faults", + "file_backed", + "free", + "inactive", + "page_ins", + "page_outs", + "purgeable", + "purged", + "reactivated", + "speculative", + "swap_ins", + "swap_outs", + "throttled", + "uncompressed", + "wired", + "zero_fill" + ], + "sectionRelativeRepoPath": "virtual_memory_info", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/virtual_memory_info.yml" + }, + { + "url": "/tables/vscode_extensions", + "title": "vscode_extensions", + "htmlId": "table--vscodeextensions--3122f67e21", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "vscode_extensions", + "installed_at", + "name", + "path", + "prerelease", + "publisher", + "publisher_id", + "uid", + "uuid", + "version" + ], + "sectionRelativeRepoPath": "vscode_extensions", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/vscode_extensions.yml" + }, + { + "url": "/tables/wifi_networks", + "title": "wifi_networks", + "htmlId": "table--wifinetworks--196d0fe380", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "wifi_networks", + "add_reason", + "added_at", + "auto_join", + "auto_login", + "captive_login_date", + "captive_portal", + "disabled", + "last_connected", + "network_name", + "passpoint", + "personal_hotspot", + "possibly_hidden", + "roaming", + "roaming_profile", + "security_type", + "ssid", + "temporarily_disabled", + "was_captive_network" + ], + "sectionRelativeRepoPath": "wifi_networks", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/wifi_networks.yml" + }, + { + "url": "/tables/wifi_status", + "title": "wifi_status", + "htmlId": "table--wifistatus--7d5af734ae", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "wifi_status", + "bssid", + "channel", + "channel_band", + "channel_width", + "country_code", + "interface", + "mode", + "network_name", + "noise", + "rssi", + "security_type", + "ssid", + "transmit_rate" + ], + "sectionRelativeRepoPath": "wifi_status", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/wifi_status.yml" + }, + { + "url": "/tables/wifi_survey", + "title": "wifi_survey", + "htmlId": "table--wifisurvey--86f4a22532", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "wifi_survey", + "bssid", + "channel", + "channel_band", + "channel_width", + "country_code", + "interface", + "network_name", + "noise", + "rssi", + "ssid" + ], + "sectionRelativeRepoPath": "wifi_survey", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/wifi_survey.yml" + }, + { + "url": "/tables/winbaseobj", + "title": "winbaseobj", + "htmlId": "table--winbaseobj--0e0dd909ed", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "winbaseobj", + "object_name", + "object_type", + "session_id" + ], + "sectionRelativeRepoPath": "winbaseobj", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwinbaseobj.yml&value=name%3A%20winbaseobj%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_crashes", + "title": "windows_crashes", + "htmlId": "table--windowscrashes--3bcda23e6b", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_crashes", + "build_number", + "command_line", + "crash_path", + "current_directory", + "datetime", + "exception_address", + "exception_code", + "exception_message", + "machine_name", + "major_version", + "minor_version", + "module", + "path", + "pid", + "process_uptime", + "registers", + "stack_trace", + "tid", + "type", + "username", + "version" + ], + "sectionRelativeRepoPath": "windows_crashes", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_crashes.yml&value=name%3A%20windows_crashes%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_eventlog", + "title": "windows_eventlog", + "htmlId": "table--windowseventlog--c368bc9838", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_eventlog", + "channel", + "computer_name", + "data", + "datetime", + "eventid", + "keywords", + "level", + "pid", + "provider_guid", + "provider_name", + "task", + "tid", + "time_range", + "timestamp", + "xpath" + ], + "sectionRelativeRepoPath": "windows_eventlog", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/windows_eventlog.yml" + }, + { + "url": "/tables/windows_events", + "title": "windows_events", + "htmlId": "table--windowsevents--b4aae30966", + "evented": true, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_events", + "computer_name", + "data", + "datetime", + "eid", + "eventid", + "keywords", + "level", + "provider_guid", + "provider_name", + "source", + "task", + "time" + ], + "sectionRelativeRepoPath": "windows_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_events.yml&value=name%3A%20windows_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_firewall_rules", + "title": "windows_firewall_rules", + "htmlId": "table--windowsfirewallrules--54886746d8", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_firewall_rules", + "action", + "app_name", + "direction", + "enabled", + "grouping", + "icmp_types_codes", + "local_addresses", + "local_ports", + "name", + "profile_domain", + "profile_private", + "profile_public", + "protocol", + "remote_addresses", + "remote_ports", + "service_name" + ], + "sectionRelativeRepoPath": "windows_firewall_rules", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/windows_firewall_rules.yml" + }, + { + "url": "/tables/windows_optional_features", + "title": "windows_optional_features", + "htmlId": "table--windowsoptionalfeatures--7fc389462f", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_optional_features", + "caption", + "name", + "state", + "statename" + ], + "sectionRelativeRepoPath": "windows_optional_features", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/windows_optional_features.yml" + }, + { + "url": "/tables/windows_search", + "title": "windows_search", + "htmlId": "table--windowssearch--3bc557a530", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_search", + "additional_properties", + "date_created", + "date_modified", + "max_results", + "name", + "owner", + "path", + "properties", + "query", + "size", + "sort", + "type" + ], + "sectionRelativeRepoPath": "windows_search", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_search.yml&value=name%3A%20windows_search%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_security_center", + "title": "windows_security_center", + "htmlId": "table--windowssecuritycenter--8c6fbc78cd", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_security_center", + "antispyware", + "antivirus", + "autoupdate", + "firewall", + "internet_settings", + "user_account_control", + "windows_security_center_service" + ], + "sectionRelativeRepoPath": "windows_security_center", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_security_center.yml&value=name%3A%20windows_security_center%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_security_products", + "title": "windows_security_products", + "htmlId": "table--windowssecurityproducts--f74ebb0ecc", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_security_products", + "name", + "remediation_path", + "signatures_up_to_date", + "state", + "state_timestamp", + "type" + ], + "sectionRelativeRepoPath": "windows_security_products", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_security_products.yml&value=name%3A%20windows_security_products%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_update_history", + "title": "windows_update_history", + "htmlId": "table--windowsupdatehistory--ef7bb6c2c1", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_update_history", + "client_app_id", + "date", + "description", + "hresult", + "operation", + "result_code", + "server_selection", + "service_id", + "support_url", + "title", + "update_id", + "update_revision" + ], + "sectionRelativeRepoPath": "windows_update_history", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwindows_update_history.yml&value=name%3A%20windows_update_history%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/windows_updates", + "title": "windows_updates", + "htmlId": "table--windowsupdates--aa61957cff", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "windows_updates", + "fullkey", + "is_default", + "key", + "locale", + "parent", + "query", + "value" + ], + "sectionRelativeRepoPath": "windows_updates", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/windows_updates.yml" + }, + { + "url": "/tables/wmi_bios_info", + "title": "wmi_bios_info", + "htmlId": "table--wmibiosinfo--e665577f28", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "wmi_bios_info", + "name", + "value" + ], + "sectionRelativeRepoPath": "wmi_bios_info", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwmi_bios_info.yml&value=name%3A%20wmi_bios_info%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/wmi_cli_event_consumers", + "title": "wmi_cli_event_consumers", + "htmlId": "table--wmiclieventconsumers--d43fbe70e9", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "wmi_cli_event_consumers", + "class", + "command_line_template", + "executable_path", + "name", + "relative_path" + ], + "sectionRelativeRepoPath": "wmi_cli_event_consumers", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwmi_cli_event_consumers.yml&value=name%3A%20wmi_cli_event_consumers%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/wmi_event_filters", + "title": "wmi_event_filters", + "htmlId": "table--wmieventfilters--04ba1150eb", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "wmi_event_filters", + "class", + "name", + "query", + "query_language", + "relative_path" + ], + "sectionRelativeRepoPath": "wmi_event_filters", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwmi_event_filters.yml&value=name%3A%20wmi_event_filters%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/wmi_filter_consumer_binding", + "title": "wmi_filter_consumer_binding", + "htmlId": "table--wmifilterconsumerbinding--c53468b489", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "wmi_filter_consumer_binding", + "class", + "consumer", + "filter", + "relative_path" + ], + "sectionRelativeRepoPath": "wmi_filter_consumer_binding", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwmi_filter_consumer_binding.yml&value=name%3A%20wmi_filter_consumer_binding%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/wmi_script_event_consumers", + "title": "wmi_script_event_consumers", + "htmlId": "table--wmiscripteventconsumers--9275e5f795", + "evented": false, + "platforms": [ + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "wmi_script_event_consumers", + "class", + "name", + "relative_path", + "script_file_name", + "script_text", + "scripting_engine" + ], + "sectionRelativeRepoPath": "wmi_script_event_consumers", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fwmi_script_event_consumers.yml&value=name%3A%20wmi_script_event_consumers%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/xprotect_entries", + "title": "xprotect_entries", + "htmlId": "table--xprotectentries--82da15dfc5", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "xprotect_entries", + "filename", + "filetype", + "identity", + "launch_type", + "name", + "optional", + "uses_pattern" + ], + "sectionRelativeRepoPath": "xprotect_entries", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/xprotect_entries.yml" + }, + { + "url": "/tables/xprotect_meta", + "title": "xprotect_meta", + "htmlId": "table--xprotectmeta--d9c759b143", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "xprotect_meta", + "developer_id", + "identifier", + "min_version", + "type" + ], + "sectionRelativeRepoPath": "xprotect_meta", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/xprotect_meta.yml" + }, + { + "url": "/tables/xprotect_reports", + "title": "xprotect_reports", + "htmlId": "table--xprotectreports--ed058eba3f", + "evented": false, + "platforms": [ + "darwin" + ], + "keywordsForSyntaxHighlighting": [ + "xprotect_reports", + "name", + "time", + "user_action" + ], + "sectionRelativeRepoPath": "xprotect_reports", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/xprotect_reports.yml" + }, + { + "url": "/tables/yara", + "title": "yara", + "htmlId": "table--yara--f7412a4474", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "yara", + "count", + "matches", + "path", + "pid_with_namespace", + "sig_group", + "sigfile", + "sigrule", + "sigurl", + "strings", + "tags" + ], + "sectionRelativeRepoPath": "yara", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/yara.yml" + }, + { + "url": "/tables/yara_events", + "title": "yara_events", + "htmlId": "table--yaraevents--a3df07297e", + "evented": true, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "yara_events", + "action", + "category", + "count", + "eid", + "matches", + "strings", + "tags", + "target_path", + "time", + "transaction_id" + ], + "sectionRelativeRepoPath": "yara_events", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fyara_events.yml&value=name%3A%20yara_events%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/ycloud_instance_metadata", + "title": "ycloud_instance_metadata", + "htmlId": "table--ycloudinstancemetadata--91cc1e1945", + "evented": false, + "platforms": [ + "darwin", + "linux", + "windows" + ], + "keywordsForSyntaxHighlighting": [ + "ycloud_instance_metadata", + "cloud_id", + "description", + "folder_id", + "hostname", + "instance_id", + "metadata_endpoint", + "name", + "serial_port_enabled", + "ssh_public_key", + "zone" + ], + "sectionRelativeRepoPath": "ycloud_instance_metadata", + "githubUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fycloud_instance_metadata.yml&value=name%3A%20ycloud_instance_metadata%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table." + }, + { + "url": "/tables/yum_sources", + "title": "yum_sources", + "htmlId": "table--yumsources--866cfa7193", + "evented": false, + "platforms": [ + "linux" + ], + "keywordsForSyntaxHighlighting": [ + "yum_sources", + "baseurl", + "enabled", + "gpgcheck", + "gpgkey", + "mirrorlist", + "name", + "pid_with_namespace" + ], + "sectionRelativeRepoPath": "yum_sources", + "githubUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/yum_sources.yml" + } + ], + "rituals": { + "handbook/demand/demand.rituals.yml": [ + { + "task": "Refresh event calendar", + "startedOn": "2023-12-31", + "frequency": "Quarterly", + "description": "https://fleetdm.com/handbook/demand#refresh-event-calendar", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#refresh-event-calendar", + "dri": "Drew-P-drawers" + }, + { + "task": "Prioritize for next sprint", + "startedOn": "2023-09-04", + "frequency": "Triweekly", + "description": "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "mikermcneil", + "autoIssue": { + "labels": [ + "#g-demand" + ], + "repo": "confidential" + } + }, + { + "task": "Settle event strategy", + "startedOn": "2024-01-02", + "frequency": "Quarterly (first Tuesday)", + "description": "https://fleetdm.com/handbook/demand#settle-event-strategy", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#settle-event-strategy", + "dri": "Drew-P-drawers" + }, + { + "task": "🫧 Pipeline sync", + "startedOn": "2024-08-29", + "frequency": "Weekly", + "description": "Allign with CRO and AEs on pipeline processes and incoming leads", + "moreInfoUrl": "", + "dri": "Drew-P-drawers" + }, + { + "task": "Optimize ads", + "startedOn": "2024-02-26", + "frequency": "Weekly", + "description": "Remove all but the top 5 perfoming ads in each evergreen campaign. Make sure ABM campaigns are using top performing evergreen ads.", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#optimize-ads-through-experimentation", + "dri": "Drew-P-drawers" + }, + { + "task": "Process pending swag requests from the website", + "startedOn": "2023-09-20", + "frequency": "Weekly", + "description": "Complete draft orders.", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#process-pending-swag-requests-from-the-website", + "dri": "Drew-P-drawers" + }, + { + "task": "Engage with the community", + "startedOn": "2023-09-20", + "frequency": "Daily", + "description": "Find relevant conversations with the community and contribute", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#engage-with-the-community", + "dri": "Drew-P-drawers" + }, + { + "task": "Publish ☁️🌈 Sprint demos", + "startedOn": "2023-11-03", + "frequency": "Triweekly", + "description": "Every release cycle, upload the ☁️🌈 Sprint demos video to YouTube", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#upload-to-youtube", + "dri": "Drew-P-drawers" + }, + { + "task": "Measure intent signals", + "startedOn": "2024-08-09", + "frequency": "Daily", + "description": "Measure intent signals and update SalesForce", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#measure-intent-signals", + "dri": "Drew-P-drawers" + }, + { + "task": "Research accounts", + "startedOn": "2024-08-09", + "frequency": "Daily", + "description": "Research SalesForce accounts and begin ABM ads", + "moreInfoUrl": "https://fleetdm.com/handbook/demand#warm-up-actions", + "dri": "Drew-P-drawers" + } + ], + "handbook/customer-success/customer-success.rituals.yml": [ + { + "task": "Prioritize for next sprint", + "startedOn": "2023-09-04", + "frequency": "Triweekly", + "description": "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "zayhanlon", + "autoIssue": { + "labels": [ + "#g-customer-success" + ], + "repo": "confidential" + } + }, + { + "task": "Process new requests", + "startedOn": "2023-09-04", + "frequency": "Daily", + "description": "Prioritize all new requests including issues and PRs within one business day.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/communications#process-new-requests", + "dri": "zayhanlon" + }, + { + "task": "Overnight customer feedback", + "startedOn": "2024-02-08", + "frequency": "Daily", + "description": "Respond to messages and alerts", + "moreInfoUrl": "https://fleetdm.com/handbook/customer-success#respond-to-messages-and-alerts", + "dri": "ksatter" + }, + { + "task": "Monitor customer Slack channels ", + "startedOn": "2024-02-08", + "frequency": "Daily", + "description": "Continuously monitor Slack for customer feedback, feature requests, reported bugs, etc., and respond in less than an hour.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/communications#customer-support-service-level-agreements-slas", + "dri": "ksatter" + }, + { + "task": "Follow-up on unresolved customer questions and concerns", + "startedOn": "2024-02-08", + "frequency": "Daily", + "description": "Follow-up with and tag appropriate personnel on customer issues and bugs in progress and items that remain unresolved.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/communications#customer-support-service-level-agreements-slas", + "dri": "ksatter" + }, + { + "task": "Prepare for customer voice", + "startedOn": "2024-02-23", + "frequency": "Weekly", + "description": "Prepare and review the health and latest updates from Fleet's key customers and active proof of concepts (POCs).", + "moreInfoUrl": "", + "dri": "patagonia121" + }, + { + "task": "Prepare customer requests for feature fest", + "startedOn": "2024-02-12", + "frequency": "Triweekly", + "description": "Check-in before the 🗣️ Product Feature Requests meeting to make sure that all information necessary has been gathered before presenting customer requests and feedback to the Product team.", + "moreInfoUrl": "", + "dri": "nonpunctual" + }, + { + "task": "Present customer requests at feature fest", + "startedOn": "2024-02-15", + "frequency": "Triweekly", + "description": "Present and advocate for requests and ideas brought to Fleet's attention by customers that are interesting from a product perspective.", + "moreInfoUrl": "", + "dri": "nonpunctual" + }, + { + "task": "Communicate release notes to stakeholders", + "startedOn": "2024-02-21", + "frequency": "Triweekly", + "description": "Update customers on new features and resolved bugs in an upcoming release.", + "moreInfoUrl": "", + "dri": "patagonia121" + }, + { + "task": "Upgrade Managed Cloud", + "startedOn": "2024-02-08", + "frequency": "Weekly", + "description": "Upgrade each Managed Cloud instance to the latest version of Fleet", + "moreInfoUrl": "https://github.com/fleetdm/fleet/releases", + "dri": "rfairburn" + } + ], + "handbook/digital-experience/digital-experience.rituals.yml": [ + { + "task": "Complete Digital Experience KPIs", + "startedOn": "2024-08-30", + "frequency": "Weekly", + "description": "Complete Digital Experience KPIs for this week", + "moreInfoUrl": "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0&range=DB1", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "fleet" + } + }, + { + "task": "Prep 1:1s for OKR planning", + "startedOn": "2024-09-09", + "frequency": "Monthly", + "description": "Add ”DISCUSS: Mike: Expectations of OKR planning“ to each e-group member's 1:1 document", + "moreInfoUrl": "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "fleet" + } + }, + { + "task": "Check browser compatibility for fleetdm.com", + "startedOn": "2024-03-06", + "frequency": "Monthly", + "description": "Use Browserstack to manually QA pages on fleetdm.com in each of the earliest supported browser versions", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#check-browser-compatibility-for-fleetdm-com", + "dri": "eashaw", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "fleet" + } + }, + { + "task": "Regenerate messaging framework", + "startedOn": "2024-07-15", + "frequency": "Quarterly", + "description": "Run through the entire website in `?utm_content=clear` mode and build a fresh outline of the headings to make sure they all still make sense.", + "moreInfoUrl": "", + "dri": "mike-j-thomas" + }, + { + "task": "Check brand fronts are up to date", + "startedOn": "2024-08-01", + "frequency": "Quarterly", + "description": "Check all brand fronts for consistancy and update as needed with the current product pitch and graphics.", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#update-a-company-brand-front", + "dri": "mike-j-thomas" + }, + { + "task": "Check production dependencies of fleetdm.com", + "startedOn": "2023-11-10", + "frequency": "Weekly", + "description": "Check for vulnerabilities on the production dependencies of fleetdm.com.", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#check-production-dependencies-of-fleetdm-com", + "dri": "eashaw", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "fleet" + } + }, + { + "task": "Check osquery Slack invitation", + "startedOn": "2023-11-10", + "frequency": "Monthly", + "description": "Check the osquery Slack invitation that is linked to from Fleet and the Fleet website to make sure it is valid.", + "moreInfoUrl": "https://fleetdm.com/slack", + "dri": "eashaw", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "fleet" + } + }, + { + "task": "Prepare for CEO office minutes", + "startedOn": "2023-12-18", + "frequency": "Daily", + "description": "Prepare the CEO office minutes calendar event and meeting agenda", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#prepare-for-ceo-office-minutes", + "dri": "SFriendLee" + }, + { + "task": "Prioritize for next sprint", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "sampfluger88", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Process the CEO's inbox", + "startedOn": "2023-07-29", + "frequency": "Daily ⏰", + "description": "Process the CEO's inbox", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#process-the-ceos-email", + "dri": "SFriendLee" + }, + { + "task": "Process all \"New requests\" on the #g-digital-experience kanban board", + "startedOn": "2023-07-29", + "frequency": "Daily ⏰", + "description": "Process and prioritize all new issues and PRs", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#process-new-requests-from-the-g-ceo-kanban-board", + "dri": "sampfluger88" + }, + { + "task": "Process the CEO's calendar", + "startedOn": "2023-07-29", + "frequency": "Daily ⏰", + "description": "Process the CEO's calendar", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#process-the-ceos-calendar", + "dri": "SFriendLee" + }, + { + "task": "Send weekly update", + "startedOn": "2023-09-15", + "frequency": "Weekly", + "description": "Send weekly update", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#send-the-weekly-update", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Process and backup E-group agenda", + "startedOn": "2023-09-20", + "frequency": "Weekly", + "description": "Process and backup E-group agenda", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#process-and-backup-sid-agenda", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Process and backup Sid agenda", + "startedOn": "2023-09-25", + "frequency": "Monthly", + "description": "Process and backup Sid agenda", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#process-and-backup-e-group-agenda", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Share recording of all hands meeting", + "startedOn": "2023-07-01", + "frequency": "Monthly", + "description": "Sharing the all hands recording", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#share-recording-of-all-hands-meeting", + "dri": "SFriendLee", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Prepare all hands deck", + "startedOn": "2023-07-01", + "frequency": "Monthly", + "description": "Preparing the all hands deck", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#preparing-for-the-all-hands", + "dri": "sampfluger88", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Prepare board deck", + "startedOn": "2023-09-25", + "frequency": "Quarterly", + "description": "Prepare slide deck for the next board meeting", + "dri": "sampfluger88" + }, + { + "task": "Process CEO GitHub review requests, mentions, and outstanding PRs", + "startedOn": "2023-07-29", + "frequency": "Daily", + "description": "Filter all action items from CEO's GitHub notifications", + "dri": "SFriendLee" + }, + { + "task": "Check LinkedIn for unread messages", + "startedOn": "2023-09-25", + "frequency": "Daily", + "description": "Prevent connections from slipping through the cracks", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#check-linkedin-for-unread-messages", + "dri": "SFriendLee" + }, + { + "task": "Downgrade unused license seats", + "startedOn": "2024-03-31", + "frequency": "Quarterly", + "description": "Downgrade unused or questionable license seats on the first Wednesday of every quarter", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#downgrade-an-unused-license-seat", + "dri": "sampfluger88" + }, + { + "task": "Communicate Fleet's potential energy to stakeholders", + "startedOn": "2024-05-01", + "frequency": "Monthly", + "description": "Via hand or automation, send a monthly update email to all investors that hold 4% equity or greater in Fleet who have opted in to receive emails on the company's progress.", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#communicate-fleets-potential-energy-to-stakeholders", + "dri": "sampfluger88", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Vanta check", + "startedOn": "2024-04-01", + "frequency": "Monthly", + "description": "Look for any new actions in Vanta due in the upcoming months and create issues to ensure they're done on time.", + "moreInfoUrl": null, + "dri": "sampfluger88", + "autoIssue": { + "labels": [ + "#g-digital-experience" + ], + "repo": "confidential" + } + }, + { + "task": "Recognize and benchmark workiversaries", + "startedOn": "2024-07-15", + "frequency": "Bimonthly", + "description": "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#recognize-employee-workiversaries", + "dri": "sampfluger88" + }, + { + "task": "Quarterly grants", + "startedOn": "2024-02-01", + "frequency": "Quarterly", + "description": "Create the equity grants GitHub issue and walk through the steps.", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#grant-equity", + "dri": "hollidayn" + }, + { + "task": "Change password of \"Integrations admin\" Salesforce account", + "startedOn": "2024-09-10", + "frequency": "Quarterly", + "description": "Log into the \"Integrations admin\" account in Salesforce and change the password to prevent a password change being required by Salesforce.", + "moreInfoUrl": "https://fleetdm.com/handbook/digital-experience#change-the-integrations-admin-salesforce-account-password", + "dri": "eashaw" + } + ], + "handbook/finance/finance.rituals.yml": [ + { + "task": "Communicate the status of customer financial actions", + "startedOn": "2024-02-12", + "frequency": "Weekly", + "description": "At the start of every week, check the Salesforce reports for past due invoices, non-invoiced opportunities, and past due renewals. Report findings to in the `#g-sales` channel.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#communicate-the-status-of-customer-financial-actions", + "dri": "ireedy", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "AP invoice monitoring", + "startedOn": "2024-04-01", + "frequency": "Weekly", + "description": "Look for new accounts payable invoices and make sure that Fleet's suppliers are paid.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#process-a-new-vendor-invoice", + "dri": "ireedy", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Complete Finance KPI inputs", + "startedOn": "2024-02-16", + "frequency": "Weekly", + "description": "Create the weekly team KPI issue, complete the finance update.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#update-weekly-kpis", + "dri": "ireedy", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Key review prep", + "startedOn": "2024-02-14", + "frequency": "Triweekly", + "description": "Prepare for this sprint's Key review meeting.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/leadership#key-reviews", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Prioritize for next sprint", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Reconcile monthly recurring expenses", + "startedOn": "2024-02-28", + "frequency": "Monthly", + "description": "Each month, update the inputs in “The numbers” spreadsheet to reflect the actuals for recurring non-personnel spend, and identify any unexpected increase or decrease in spend.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#reconcile-monthly-recurring-expenses", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Monthly accounting", + "startedOn": "2024-02-28", + "frequency": "Monthly", + "description": "Create the monthly close GitHub issue and walk through the steps. This process includes fulfilling the monthly reporting requirement for SVB.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#process-monthly-accounting", + "dri": "ireedy", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Run regular payroll", + "startedOn": "2024-02-24", + "frequency": "Monthly", + "description": "Verify auto-populated payroll for all full time employees is accurate, and approve for processing.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#run-payroll", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Monthly mail review", + "startedOn": "2024-04-15", + "frequency": "Monthly", + "description": "Review and clear mail incurring storage fees", + "moreInfoUrl": null, + "dri": "ireedy", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Run US contractor payroll", + "startedOn": "2024-02-28", + "frequency": "Monthly", + "description": "Manually process US contractor payroll by verifying and syncing time contractor worked, then processing payment.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#run-us-contractor-payroll", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Run US commission payroll", + "startedOn": "2024-01-31", + "frequency": "Monthly", + "description": "Verify closed-won deal amounts, use commission calculators to determine commissions owed, and process payroll.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#run-us-commission-payroll", + "dri": "jostableford", + "autoIssue": { + "labels": [ + "#g-finance" + ], + "repo": "confidential" + } + }, + { + "task": "Run bonus payroll", + "startedOn": "2024-01-31", + "frequency": "Quarterly", + "description": "Verify completion of any objective or outcome based bonus plans, and process payroll.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#run-us-commission-payroll", + "dri": "jostableford" + }, + { + "task": "Review state filings for the previous quarter", + "startedOn": "2024-07-19", + "frequency": "Quarterly", + "description": "Verify that state filings have been successfully submitted for the previous quarter", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#review-state-employment-tax-filings-for-the-previous-quarter", + "dri": "ireedy" + }, + { + "task": "Investor reporting", + "startedOn": "2024-03-31", + "frequency": "Quarterly", + "description": "Provide updated metrics for CRV in Chronograph.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#report-quarterly-numbers-in-chronograph", + "dri": "ireedy" + }, + { + "task": "Quartlery finance check", + "startedOn": "2024-03-31", + "frequency": "Quarterly", + "description": "Every quarter, we check Quickbooks Online (QBO) for discrepancies and follow up with accounting providers for any quirks found.", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#check-finances-for-quirks", + "dri": "jostableford" + }, + { + "task": "Deliver annual report for venture line", + "startedOn": "2024-12-01", + "frequency": "Annually", + "description": "Within 60 days of the new year, provide financial statements to SVB, along with board-approved projections for the new year", + "moreInfoUrl": "https://fleetdm.com/handbook/finance#deliver-annual-report-for-venture-line", + "dri": "jostableford" + }, + { + "task": "Tax preparation", + "startedOn": "2024-02-01", + "frequency": "Annually", + "description": "Provide information to tax team with Deloitte and assist with filing and paying state and federal returns", + "moreInfoUrl": null, + "dri": "jostableford" + } + ], + "handbook/engineering/engineering.rituals.yml": [ + { + "task": "Pull request review", + "startedOn": "2023-08-09", + "frequency": "Daily", + "description": "Engineers go through pull requests for which their review has been requested.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "lukeheath" + }, + { + "task": "Engineering group discussions", + "startedOn": "2023-08-09", + "frequency": "Daily", + "description": "Engineers go through pull requests for which their review has been requested.", + "moreInfoUrl": null, + "dri": "lukeheath" + }, + { + "task": "Oncall handoff", + "startedOn": "2023-08-09", + "frequency": "Weekly", + "description": "Hand off the oncall engineering responsibilities to the next oncall engineer.", + "moreInfoUrl": null, + "dri": "lukeheath" + }, + { + "task": "Vulnerability alerts (fleetdm.com)", + "startedOn": "2023-08-09", + "frequency": "Weekly", + "description": "Review and remediate or dismiss vulnerability alerts for the fleetdm.com codebase on GitHub.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/security", + "dri": "eashaw" + }, + { + "task": "Vulnerability alerts (frontend)", + "startedOn": "2023-08-09", + "frequency": "Weekly", + "description": "Review and remediate or dismiss vulnerability alerts for the Fleet frontend codebase (and related JS) on GitHub.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/security", + "dri": "lukeheath" + }, + { + "task": "Vulnerability alerts (backend)", + "startedOn": "2023-08-09", + "frequency": "Weekly", + "description": "Review and remediate or dismiss vulnerability alerts for the Fleet backend codebase (and all Go code) on GitHub.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/security", + "dri": "lukeheath" + }, + { + "task": "Release candidate ritual", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Go through the process of creating a release candidate.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/blob/main/tools/release/README.md#minor-release-typically-end-of-sprint", + "dri": "lukeheath" + }, + { + "task": "Release ritual", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Go through the process of releasing the next iteration of Fleet.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md", + "dri": "lukeheath" + }, + { + "task": "Create patch release branch", + "startedOn": "2023-08-09", + "frequency": "Every patch release", + "description": "Go through the process of creating a patch release branch, cherry picking commits, and pushing the branch to github.com/fleetdm/fleet.", + "moreInfoUrl": "https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#patch-releases", + "dri": "lukeheath" + }, + { + "task": "Bug review", + "startedOn": "2023-08-09", + "frequency": "Weekly", + "description": "Review bugs that are in QA's inbox.", + "moreInfoUrl": "https://www.fleetdm.com/handbook/company/product-groups#inbox", + "dri": "xpkoala" + }, + { + "task": "QA report", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Every release cycle, on the Monday of release week, update the DRI for the release ritual on status of testing.", + "moreInfoUrl": null, + "dri": "xpkoala" + }, + { + "task": "Release QA", + "startedOn": "2023-08-09", + "frequency": "Triweekly", + "description": "Every release cycle, by end of day Friday of release week, move all issues to the ”✅ Ready for release” column on the #g-mdm and #g-endpoint-ops sprint boards.", + "moreInfoUrl": null, + "dri": "xpkoala" + }, + { + "task": "Check ongoing events", + "startedOn": "2024-02-09", + "frequency": "Daily", + "description": "Check event issues and complete steps.", + "moreInfoUrl": "https://fleetdm.com/handbook/engineering#book-an-event", + "dri": "spokanemac" + } + ], + "handbook/sales/sales.rituals.yml": [ + { + "task": "Close leads contacted ≥7 days ago", + "startedOn": "2024-07-05", + "frequency": "Daily", + "description": "Close all of your leads in the 'Attempted to contact' stage and which have been there for 7 or more days. If follow-up is appropriate, and won't be bothersome, it can be done after closing the lead. (A new lead can always be opened for the contact later.)", + "moreInfoUrl": "", + "dri": "Every AE" + }, + { + "task": "Prioritize for next sprint", + "startedOn": "2023-09-04", + "frequency": "Triweekly", + "description": "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "alexmitchelliii", + "autoIssue": { + "labels": [ + "#g-sales" + ], + "repo": "confidential" + } + }, + { + "task": "g-sales standup", + "startedOn": "2023-09-04", + "frequency": "Daily", + "description": "Review progress on priorities for Sprint. Discuss previous day accomplishments, goals for today and any blockers.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible", + "dri": "alexmitchelliii" + }, + { + "task": "Opportunity pipeline review", + "startedOn": "2023-09-04", + "frequency": "Weekly", + "description": "Review status of sales opportunities and discuss next steps.", + "moreInfoUrl": "https://fleetdm.com/handbook/customers#review-rep-activity", + "dri": "alexmitchelliii", + "autoIssue": { + "labels": [ + "#g-sales" + ], + "repo": "confidential" + } + }, + { + "task": "Review rep activity", + "startedOn": "2023-09-18", + "frequency": "Monthly", + "description": "https://fleetdm.com/handbook/customers#review-rep-activity", + "moreInfoUrl": "https://fleetdm.com/handbook/customers#review-rep-activity", + "dri": "alexmitchelliii" + } + ], + "handbook/product-design/product-design.rituals.yml": [ + { + "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": null, + "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": "https://fleetdm.com/handbook/company/product-groups#feature-fest", + "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": null, + "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": null, + "dri": "noahtalerman" + }, + { + "task": "🦢🗣 Design review", + "startedOn": "2024-03-07", + "frequency": "Daily", + "description": "On Mondays, contributors present wireframes in 'Feedback' mode and anyone can give feedback. 'Final review' mode during all other days and only Head of Product Design + CTO + Product Designers give feedback.", + "moreInfoUrl": "https://fleetdm.com/handbook/company/product-groups#design-reviews", + "dri": "noahtalerman" + }, + { + "task": "🦢🔄 Product design sync", + "startedOn": "2023-07-11", + "frequency": "Weekly", + "description": "Weekly time to chat about product design work (design reviews, conventions & best practices, using Figma, etc.)", + "moreInfoUrl": "https://docs.google.com/document/d/1GDEcXuTUjHI2CD9Jqega_GyF9DL6-PBmcyJpj55Lmos/edit", + "dri": "noahtalerman" + }, + { + "task": "🦢🗣 Product office hours", + "startedOn": "2023-07-11", + "frequency": "Weekly", + "description": "Head of Product Design + any other contributors who would like to attend. 30 minutes reserved to talk about any product.", + "moreInfoUrl": "https://docs.google.com/document/d/1Znyp2a9qcM9JdYHrzLudvcPwEdhnCg7RiKi22s8yGWw/edit", + "dri": "noahtalerman" + }, + { + "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 #g-mdm and #g-endpoint-ops Slack channel.", + "moreInfoUrl": null, + "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": null, + "dri": "noahtalerman" + }, + { + "task": "Pre-sprint prioritization", + "startedOn": "2024-02-27", + "frequency": "Triweekly", + "description": "Discuss what stories weren't completed in the previous sprint. Record the number of stories in KPIs. Align on priorities for upcoming sprint.", + "dri": "noahtalerman" + } + ] + }, + "testimonials": [ + { + "quote": "Yes Sir. Great tools for the everyday open-source geeks 💯", + "quoteAuthorName": "Alvaro Gutierrez", + "quoteAuthorProfileImageFilename": "testimonial-authour-alvaro-gutierrez-100x100@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/aantoniogutierrez/", + "quoteAuthorJobTitle": "Technology Evangelist", + "productCategories": [ + "Endpoint operations" + ] + }, + { + "quote": "Fleet / osquery are some of my favorite open source detection tooling.", + "quoteAuthorName": "Joe Pistone", + "quoteAuthorProfileImageFilename": "testimonial-author-joe-pistone-100x100@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/josephpistone/", + "quoteAuthorJobTitle": "Manager, Security Operations", + "productCategories": [ + "Endpoint operations" + ] + }, + { + "quote": "I had to answer some really complex questions for a compliance audit, and I was able to do it in about 15 minutes by munging some data together via a few queries into a csv. It took me longer to remember how to use `xsv` than to actually put together the report. If you aren't using osquery in your environment, you should be.", + "quoteAuthorName": "Charles Zaffery", + "quoteAuthorProfileImageFilename": "testimonial-author-charles-zaffery-48x48@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/charleszaffery/", + "quoteAuthorJobTitle": "Principle Computer Janitor", + "productCategories": [ + "Vulnerability management" + ] + }, + { + "quote": "The visibility down into the assets covered by the agent is phenomenal. Fleet has become the central source for a lot of things.", + "quoteAuthorName": "Andre Shields", + "quoteAuthorProfileImageFilename": "testimonial-author-andre-shields-48x48@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/andre-shields/", + "quoteAuthorJobTitle": "Staff Cybersecurity Engineer, Vulnerability Management", + "youtubeVideoUrl": "https://www.youtube.com/watch?v=siXy9aanOu4", + "productCategories": [ + "Endpoint operations", + "Vulnerability management" + ], + "videoIdForEmbed": "siXy9aanOu4" + }, + { + "quote": "I love the steady and consistent delivery of features that help teams work how they want to work, not how your product dictates they work.", + "quoteImageFilename": "social-proof-logo-atlassian-192x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/danielgrzelak/", + "quoteAuthorName": "Dan Grzelak", + "quoteAuthorProfileImageFilename": "testimonial-author-daniel-grzelak-48x48@2x.png", + "quoteAuthorJobTitle": "Security Chief of Staff", + "productCategories": [ + "Endpoint operations", + "Vulnerability management", + "Device management" + ], + "imageHeight": 32 + }, + { + "quote": "We can build it exactly the way we want it. Which is just not possible on other platforms.", + "quoteAuthorName": "Austin Anderson", + "quoteAuthorProfileImageFilename": "testimonial-author-austin-anderson-48x48@2x.png", + "quoteAuthorJobTitle": "Cybersecurity team senior manager", + "quoteLinkUrl": "https://www.linkedin.com/in/austin-anderson-73172185/", + "youtubeVideoUrl": "https://www.youtube.com/watch?v=G5Ry_vQPaYc", + "productCategories": [ + "Endpoint operations", + "Vulnerability management" + ], + "videoIdForEmbed": "G5Ry_vQPaYc" + }, + { + "quote": "Exciting. This is a team that listens to feedback.", + "quoteImageFilename": "social-proof-logo-uber-71x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/eriknicolasgomez/", + "quoteAuthorName": "Erik Gomez", + "quoteAuthorProfileImageFilename": "testimonial-author-erik-gomez-48x48@2x.png", + "quoteAuthorJobTitle": "Staff Client Platform Engineer", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "imageHeight": 32 + }, + { + "quote": "Context is king for device data, and Fleet provides a way to surface that information to our other teams and partners.", + "quoteAuthorName": "Nick Fohs", + "quoteAuthorProfileImageFilename": "testimonial-author-nick-fohs-24x24@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/nickfohs/", + "quoteAuthorJobTitle": "Systems and infrastructure manager", + "youtubeVideoUrl": "https://www.youtube.com/watch?v=fs5ULAR4e4A", + "productCategories": [ + "Endpoint operations", + "Device management", + "Vulnerability management" + ], + "videoIdForEmbed": "fs5ULAR4e4A" + }, + { + "quote": "Keeping up with the latest issues in endpoint security is a never-ending task, because engineers have to regularly ensure every laptop and server is still sufficiently patched and securely configured. The problem is, software vendors release new versions all the time, and no matter how much you lock it down, end users find ways to change things.", + "quoteImageFilename": "social-proof-logo-lyft-47x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/nwaisman/", + "quoteAuthorName": "Nico Waisman", + "quoteAuthorProfileImageFilename": "testimonial-author-nico-waisman-48x48@2x.png", + "quoteAuthorJobTitle": "CISO of Lyft", + "productCategories": [ + "Endpoint operations", + "Vulnerability management" + ], + "imageHeight": 32 + }, + { + "quote": "Having the freedom to take full advantage of the product is one of the reasons why I always support open-source products with a commercially-backed company, like Fleet.", + "quoteImageFilename": "social-proof-logo-lyft-47x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/posts/nwaisman_movingtofleet-activity-7156319785981509632-bk_W", + "quoteAuthorName": "Nico Waisman", + "quoteAuthorProfileImageFilename": "testimonial-author-nico-waisman-48x48@2x.png", + "quoteAuthorJobTitle": "CISO of Lyft", + "productCategories": [ + "Device management" + ], + "imageHeight": 32 + }, + { + "quote": "Fleet has been highly effective for our needs. We appreciate your team for always being so open to hearing our feedback.", + "quoteAuthorName": "Kenny Botelho", + "quoteAuthorProfileImageFilename": "testimonial-author-kenny-botelho-48x48@2x.png", + "quoteAuthorJobTitle": "Client Platform IT Engineer / Leader", + "quoteLinkUrl": "https://www.linkedin.com/in/kennybotelho/", + "productCategories": [ + "Endpoint operations", + "Device management" + ] + }, + { + "quote": "Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.", + "quoteImageFilename": "social-proof-logo-stripe-67x32@2x.png", + "quoteAuthorName": "Wes Whetstone", + "quoteAuthorProfileImageFilename": "testimonial-author-wes-whetstone-48x48@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/jckwhet/", + "quoteAuthorJobTitle": "Staff CPE at Stripe", + "productCategories": [ + "Endpoint operations", + "Device management" + ], + "imageHeight": 32 + }, + { + "quote": "Fleet’s come a long way - to now being the top open-source osquery manager.", + "quoteImageFilename": "social-proof-logo-atlassian-192x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/bshak/", + "quoteAuthorName": "Brendan Shaklovitz", + "quoteAuthorProfileImageFilename": "testimonial-author-brendan-shaklovitz-48x48@2x.png", + "quoteAuthorJobTitle": "Senior SRE", + "productCategories": [ + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "quote": "It’s great to see the new release of Fleet containing some really cool new features that make osquery much more usable in practical environments. I’m really impressed with the work that Zach Wasserman and the crew are doing at Fleet.", + "quoteImageFilename": "social-proof-logo-osquery-124x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/marpaia/", + "quoteAuthorName": "Mike Arpaia", + "quoteAuthorProfileImageFilename": "testimonial-author-mike-arpaia-48x48@2x.png", + "quoteAuthorJobTitle": "Creator of osquery", + "productCategories": [ + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "quote": "Osquery is one of the best tools out there and Fleet makes it even better. Highly recommend it if you want to monitor, detect and investigate threats on a scale and also for infra/sys admin. I have used it on 15k servers and it’s really scalable.", + "quoteImageFilename": "social-proof-logo-salesforce-48x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/anelshaer/", + "quoteAuthorName": "Ahmed Elshaer", + "quoteAuthorProfileImageFilename": "testimonial-author-ahmed-elshaer-48x48@2x.png", + "quoteAuthorJobTitle": "DFIR, Blue Teaming, SecOps", + "productCategories": [ + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "quote": "With the power of osquery, you need a scalable & resilient platform to manage your workloads. Fleet is the \"just right\" open-source, enterprise grade solution.", + "quoteImageFilename": "social-proof-logo-comcast-91x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/abubakar-yousafzai-b7213659/", + "quoteAuthorName": "Abubakar Yousafzai", + "quoteAuthorProfileImageFilename": "testimonial-author-abubakar-yousafzai-48x48@2x.png", + "quoteAuthorJobTitle": "Security Software Development & Engineering", + "productCategories": [ + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "quote": "One of the best teams out there to go work for and help shape security platforms.", + "quoteImageFilename": "social-proof-logo-deloitte-130x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/neondhruv/", + "quoteAuthorName": "Dhruv Majumdar", + "quoteAuthorProfileImageFilename": "testimonial-author-dhruv-majumdar-48x48@2x.png", + "quoteAuthorJobTitle": "Director Of Cyber Risk & Advisory", + "productCategories": [ + "Vulnerability management", + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "quote": "Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didn’t expect. It spread so naturally, even our corporate and infrastructure teams want to run it.", + "quoteAuthorName": "Charles Zaffery", + "quoteLinkUrl": "https://www.linkedin.com/in/charleszaffery/", + "quoteAuthorJobTitle": "Principle computer janitor", + "quoteAuthorProfileImageFilename": "testimonial-author-charles-zaffery-48x48@2x.png", + "youtubeVideoUrl": "https://www.youtube.com/watch?v=nRbZJflWqCo", + "productCategories": [ + "Endpoint operations" + ], + "videoIdForEmbed": "nRbZJflWqCo" + }, + { + "quote": "I don't want one bad actor to brick my fleet, I want them to make a pull request first.", + "quoteAuthorName": "Matt Carr", + "quoteAuthorJobTitle": "CPE manager", + "quoteAuthorProfileImageFilename": "testimonial-author-matt-carr-48x48@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/mathewcarr/", + "productCategories": [ + "Device management" + ] + }, + { + "quote": "I wanted an easy way to control osquery configurations, and I wanted to stream data as fast as possible into Snowflake. No other solution jumped out to solve those things except for Fleet.", + "quoteAuthorName": "Tom Larkin", + "quoteAuthorJobTitle": "IT Engineering Manager", + "quoteAuthorProfileImageFilename": "testimonial-author-tom-larkin-48x48@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/thlarkin/", + "youtubeVideoUrl": "https://www.youtube.com/watch?v=nkjg_hNe86Q", + "productCategories": [ + "Endpoint operations" + ], + "videoIdForEmbed": "nkjg_hNe86Q" + }, + { + "quote": "Something I really appreciate about working with you guys is that it doesn't feel like I'm talking to a vendor. It actually feels like I'm talking to my team, and I really appreciate it.", + "quoteImageFilename": "social-proof-logo-deloitte-130x32@2x.png", + "quoteLinkUrl": "https://www.linkedin.com/in/cmajumdar/", + "quoteAuthorName": "Chandra Majumdar", + "quoteAuthorProfileImageFilename": "testimonial-author-chandra-majumdar-48x48@2x.png", + "quoteAuthorJobTitle": "Partner - Cyber and Strategic Risk", + "productCategories": [ + "Vulnerability management", + "Endpoint operations" + ], + "imageHeight": 32 + }, + { + "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" + ], + "imageHeight": 132 + } + ], + "openPositions": [ + { + "jobTitle": "🚀 Software Engineer", + "url": "/handbook/company/open-positions/software-engineer" + }, + { + "jobTitle": "🐋 Account Executive", + "url": "/handbook/company/open-positions/account-executive" + } + ], + "compiledPagePartialsAppPath": "views/partials/built-from-markdown" } } diff --git a/website/api/controllers/admin/view-email-template-preview.js b/website/api/controllers/admin/view-email-template-preview.js index 5692353463..4e13a0cd32 100644 --- a/website/api/controllers/admin/view-email-template-preview.js +++ b/website/api/controllers/admin/view-email-template-preview.js @@ -117,19 +117,22 @@ module.exports = { case 'email-nurture-stage-three': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-nurture-stage-four': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-nurture-stage-five': layout = 'layout-nurture-email'; fakeData = { - firstName: 'Sage' + firstName: 'Sage', + emailAddress: 'sage@example.com', }; break; case 'email-deal-registration': @@ -152,6 +155,14 @@ module.exports = { notes: 'Fake organization 2 is looking for a managed cloud MDM solution with a name that ends with "eet"', }; break; + case 'email-contact-form': + fakeData = { + firstName: 'Jane', + lastName: 'Williamson', + emailAddress: 'jane@example.com', + message: 'Hi, this is a contact form message!', + }; + break; default: layout = 'layout-email-newsletter'; fakeData = { diff --git a/website/api/controllers/articles/view-articles.js b/website/api/controllers/articles/view-articles.js index 8f28c6c5c4..b7f11213eb 100644 --- a/website/api/controllers/articles/view-articles.js +++ b/website/api/controllers/articles/view-articles.js @@ -48,6 +48,7 @@ module.exports = { return page; } }); + articles = _.sortBy(articles, 'meta.publishedOn'); } let pageTitleForMeta = 'Fleet blog'; @@ -107,6 +108,7 @@ module.exports = { currentSection, pageTitleForMeta, pageDescriptionForMeta, + algoliaPublicKey: sails.config.custom.algoliaPublicKey, }; } diff --git a/website/api/controllers/articles/view-basic-article.js b/website/api/controllers/articles/view-basic-article.js index 17b997f5f7..6648cebc58 100644 --- a/website/api/controllers/articles/view-basic-article.js +++ b/website/api/controllers/articles/view-basic-article.js @@ -86,6 +86,7 @@ module.exports = { pageImageForMeta: thisPage.meta.articleImageUrl || undefined, articleCategorySlug, currentSection, + algoliaPublicKey: sails.config.custom.algoliaPublicKey, }; } diff --git a/website/api/controllers/customers/save-billing-info-and-subscribe.js b/website/api/controllers/customers/save-billing-info-and-subscribe.js index 31f6e7df7e..6369ebb06c 100644 --- a/website/api/controllers/customers/save-billing-info-and-subscribe.js +++ b/website/api/controllers/customers/save-billing-info-and-subscribe.js @@ -162,6 +162,22 @@ module.exports = { } }); + let todayOn = new Date(); + let isoTimestampForDescription = todayOn.toISOString(); + sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: this.req.me.emailAddress, + firstName: this.req.me.firstName, + lastName: this.req.me.lastName, + organization: this.req.me.organization, + description: `Purchased a self-service Fleet Premium license on ${isoTimestampForDescription.split('T')[0]} for ${quoteRecord.numberOfHosts} host${quoteRecord.numberOfHosts > 1 ? 's' : ''}.` + }).exec((err)=>{ + if(err){ + sails.log.warn(`Background task failed: When a user (email: ${this.req.me.emailAddress} purchased a self-service Fleet premium subscription, a Contact and Account record could not be created/updated in the CRM.`, err); + } + return; + }); + + } diff --git a/website/api/controllers/deliver-contact-form-message.js b/website/api/controllers/deliver-contact-form-message.js index bf0a8030fd..37e5693f5c 100644 --- a/website/api/controllers/deliver-contact-form-message.js +++ b/website/api/controllers/deliver-contact-form-message.js @@ -67,7 +67,7 @@ module.exports = { } await sails.helpers.http.post(sails.config.custom.slackWebhookUrlForContactForm, { - text: `New contact form message: (Remember: we have to email back; can't just reply to this thread.) cc @sales `+ + text: `New contact form message: (Remember: we have to email back; can't just reply to this thread.)`+ `Name: ${firstName + ' ' + lastName}, Email: ${emailAddress}, Message: ${message ? message : 'No message.'}` }); @@ -75,7 +75,7 @@ module.exports = { emailAddress: emailAddress, firstName: firstName, lastName: lastName, - leadSource: 'Website - Contact forms', + contactSource: 'Website - Contact forms', description: `Sent a contact form message: ${message}`, }).exec((err)=>{// Use .exec() to run the salesforce helpers in the background. if(err) { diff --git a/website/api/controllers/deliver-talk-to-us-form-submission.js b/website/api/controllers/deliver-talk-to-us-form-submission.js index 6dd53f88be..a91c7202e3 100644 --- a/website/api/controllers/deliver-talk-to-us-form-submission.js +++ b/website/api/controllers/deliver-talk-to-us-form-submission.js @@ -75,15 +75,16 @@ module.exports = { } if(numberOfHosts >= 700){ - sails.helpers.salesforce.updateOrCreateContactAndAccountAndCreateLead.with({ + sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ emailAddress: emailAddress, firstName: firstName, lastName: lastName, organization: organization, - numberOfHosts: numberOfHosts, primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, - leadSource: 'Website - Contact forms', - leadDescription: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + contactSource: 'Website - Contact forms', + description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + psychologicalStage: '4 - Has use case', + psychologicalStageChangeReason: 'Website - Contact forms' }).exec((err)=>{ if(err) { sails.log.warn(`Background task failed: When a user submitted the "Talk to us" form, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err); @@ -96,7 +97,7 @@ module.exports = { lastName: lastName, organization: organization, primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, - leadSource: 'Website - Contact forms', + contactSource: 'Website - Contact forms', description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Let\'s get you set up!" event.`, }).exec((err)=>{ if(err) { diff --git a/website/api/controllers/entrance/signup.js b/website/api/controllers/entrance/signup.js index 8d723cbc46..c9129ac3c6 100644 --- a/website/api/controllers/entrance/signup.js +++ b/website/api/controllers/entrance/signup.js @@ -144,7 +144,7 @@ the account verification message.)`, firstName: firstName, lastName: lastName, organization: organization, - leadSource: 'Website - Sign up' + contactSource: 'Website - Sign up' }).exec((err)=>{ if(err){ sails.log.warn(`Background task failed: When a user (email: ${newEmailAddress} signed up for a fleetdm.com account, a Contact and Account record could not be created/updated in the CRM.`, err); diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index 4f7b3a2922..62faf37929 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -21,6 +21,7 @@ module.exports = { 'what-does-your-team-manage-eo-it', 'what-does-your-team-manage-vm', 'what-do-you-manage-mdm', + 'cross-platform-mdm', 'is-it-any-good', 'what-did-you-think', 'deploy-fleet-in-your-environment', @@ -86,28 +87,25 @@ module.exports = { // 'how-many-hosts': Stage 4/5/6 // 'will-you-be-self-hosting': Stage 5/6 // 'what-are-you-working-on-eo-security' - // - no-use-case-yet: » Stage 2/3 (depends on answer from 'have-you-ever-used-fleet' step) // - All other options » Stage 4 // 'what-does-your-team-manage-eo-it' - // - no-use-case-yet: » Stage 2/3 (depends on answer from 'have-you-ever-used-fleet' step) // - All other options » Stage 4 // 'what-does-your-team-manage-vm' - // - no-use-case-yet: » Stage 2/3 (depends on answer from 'have-you-ever-used-fleet' step) // - All other options » Stage 4 // 'what-do-you-manage-mdm' - // - no-use-case-yet: » Stage 2/3 (depends on answer from 'have-you-ever-used-fleet' step) + // - no-use-case-yet: » Stage 3 // - All other options » Stage 4 - // 'is-it-any-good': Stage 2/3/4 (depends on answer from 'have-you-ever-used-fleet' & the buying situation specific step) + // 'is-it-any-good': Stage 3/4 (depends on answer from 'have-you-ever-used-fleet' & the buying situation specific step) // 'what-did-you-think' - // - host-fleet-for-me » Stage 4 - // - deploy-fleet-in-environment » Stage 4 + // - host-fleet-for-me » Stage 5 + // - deploy-fleet-in-environment » Stage 5 // - let-me-think-about-it » Stage 2 // FUTURE: Should the step about deploying fleet in your env be here? (For same reason is-it-any-good is here: when navigating back then forwards?) // 'how-was-your-deployment' // - up-and-running » Stage 5 - // - kinda-stuck » Stage 4 (...at best! Still got the use case.) - // - havent-gotten-to-it » Stage 4 (same as above) - // - changed-mind-want-managed-deployment » Stage 4 (same as above) + // - kinda-stuck » Stage 5 + // - havent-gotten-to-it » Stage 5 + // - changed-mind-want-managed-deployment » Stage 5 // - decided-to-not-use-fleet » Stage 2 // 'whats-left-to-get-you-set-up' // - need-premium-license-key » No change (Stage ??) @@ -124,17 +122,13 @@ module.exports = { } else if(currentStep === 'what-are-you-using-fleet-for') { psychologicalStage = '2 - Aware'; } else if(currentStep === 'have-you-ever-used-fleet') { - if(['yes-deployed'].includes(valueFromFormData)) { + if(valueFromFormData === 'yes-deployed') { // If the user has Fleet deployed, set their stage to 6. psychologicalStage = '6 - Has team buy-in'; - } else if(valueFromFormData === 'yes-recently-deployed'){ + } else if(valueFromFormData === 'yes-recently-deployed') { psychologicalStage = '5 - Personally confident'; - } else if(valueFromFormData === 'yes-deployed-local'){ - // If they've tried Fleet locally, set their stage to 3. - psychologicalStage = '3 - Intrigued'; } else { - // Otherwise, we'll just assume liu're only aware. Maybe liu don't fully grasp what Fleet can do. - psychologicalStage = '2 - Aware'; + psychologicalStage = '3 - Intrigued'; } } else { // If the user submitted any other step, we'll set variables using the answers to the previous questions. @@ -144,28 +138,32 @@ module.exports = { let hasUsedFleetAnswer = questionnaireProgress['have-you-ever-used-fleet'].fleetUseStatus; if(['what-are-you-working-on-eo-security','what-does-your-team-manage-eo-it','what-does-your-team-manage-vm','what-do-you-manage-mdm'].includes(currentStep)){ if(valueFromFormData === 'no-use-case-yet') { - // Check the user's answer to the previous question - if(hasUsedFleetAnswer === 'yes-deployed-local'){ - // If they've tried Fleet locally, set their stage to 3. - psychologicalStage = '3 - Intrigued'; - } else { - psychologicalStage = '2 - Aware'; - } + psychologicalStage = '3 - Intrigued'; } else {// Otherwise, they have a use case and will be set to stage 4. psychologicalStage = '4 - Has use case'; } + // When the user submits the step before the "Is it any good?" step, we will generate them a 30 day Trial key for Fleet Premium that they can use with fleetctl preview + if(!userRecord.fleetPremiumTrialLicenseKey) { + let thirtyDaysFromNowAt = Date.now() + (1000 * 60 * 60 * 24 * 30); + let trialLicenseKeyForThisUser = await sails.helpers.createLicenseKey.with({ + numberOfHosts: 10, + organization: this.req.me.organization ? this.req.me.organization : 'Fleet Premium trial', + expiresAt: thirtyDaysFromNowAt, + }); + // Save the trial license key to the DB record for this user. + await User.updateOne({id: this.req.me.id}) + .set({ + fleetPremiumTrialLicenseKey: trialLicenseKeyForThisUser, + fleetPremiumTrialLicenseKeyExpiresAt: thirtyDaysFromNowAt, + }); + } } else if(currentStep === 'is-it-any-good') { if(currentSelectedBuyingSituation === 'mdm') { // Since the mdm use case question is the only buying situation-specific question where a use case can't // be selected, we'll check the user's previous answers before changing their psyStage if(questionnaireProgress['what-do-you-manage-mdm'].mdmUseCase === 'no-use-case-yet'){ // Check the user's answer to the have-you-ever-used-fleet question. - if(hasUsedFleetAnswer === 'yes-deployed-local') { - // If they've tried Fleet locally, set their stage to 3. - psychologicalStage = '3 - Intrigued'; - } else { - psychologicalStage = '2 - Aware'; - } + psychologicalStage = '3 - Intrigued'; } else { psychologicalStage = '4 - Has use case'; } @@ -177,27 +175,22 @@ module.exports = { // If the user selects "Let me think about it", set their psyStage to 2. if(valueFromFormData === 'let-me-think-about-it') { psychologicalStage = '2 - Aware'; - } else if (['deploy-fleet-in-environment','host-fleet-for-me'].includes(valueFromFormData)) { - psychologicalStage = '4 - Has use case'; + } else if (['host-fleet-for-me', 'deploy-fleet-in-environment'].includes(valueFromFormData)) { + psychologicalStage = '5 - Personally confident'; } else { require('assert')(false,'This should never happen.'); } } else if(currentStep === 'how-was-your-deployment') { if(valueFromFormData === 'decided-to-not-use-fleet') { psychologicalStage = '2 - Aware'; - } else if(valueFromFormData === 'up-and-running'){ + } else if(['up-and-running', 'changed-mind-want-managed-deployment', 'kinda-stuck', 'havent-gotten-to-it'].includes(valueFromFormData)){ psychologicalStage = '5 - Personally confident'; - } else if(['kinda-stuck', 'havent-gotten-to-it', 'changed-mind-want-managed-deployment'].includes(valueFromFormData)){ - psychologicalStage = '4 - Has use case'; } else { require('assert')(false,'This should never happen.'); } } else if (currentStep === 'whats-left-to-get-you-set-up') { // FUTURE: do more stuff (for now this always acts like 'no change') } else if(currentStep === 'how-many-hosts') { if(['yes-deployed'].includes(hasUsedFleetAnswer)) { psychologicalStage = '6 - Has team buy-in'; - } else if(['yes-recently-deployed'].includes(hasUsedFleetAnswer)){ - psychologicalStage = '5 - Personally confident'; } else { - // IWMIH then we want Fleet to host for us (either because we wanted that from the get-go, or we backtracked because deploying looked too time-consuming) - psychologicalStage = '4 - Has use case'; + psychologicalStage = '5 - Personally confident'; } } else if(currentStep === 'will-you-be-self-hosting') { if(['yes-deployed'].includes(hasUsedFleetAnswer)) { @@ -209,9 +202,30 @@ module.exports = { psychologicalStage = '2 - Aware'; }//fi }//fi - + // Set the user's answer to the current step. + questionnaireProgress[currentStep] = formData; + // Clone the questionnaireProgress to prevent any mutations from sending it through the updateOne Waterline method. + let getStartedProgress = _.clone(questionnaireProgress); + let questionnaireProgressAsAFormattedString = undefined;// Default to undefined. + // Using a try catch block to handle errors from JSON.stringify. + try { + questionnaireProgressAsAFormattedString = JSON.stringify(getStartedProgress) + .replace(/[\{|\}|"]/g, '')// Remove the curly braces and quotation marks wrapping JSON objects + .replace(/,/g, '\n')// Replace commas with newlines. + .replace(/:\w+:/g, ':\t');// Replace the key from the formData with a color and tab, (e.g., what-are-you-using-fleet-for:primaryBuyingSituation:eo-security, » what-are-you-using-fleet-for: eo-security) + } catch(err){ + sails.log.warn(`When converting a user's (email: ${this.req.me.emailAddress}) getStartedQuestionnaireAnswers to a formatted string to send to the CRM, and error occurred`, err); + } // Only update CRM records if the user's psychological stage changes. if(psychologicalStage !== userRecord.psychologicalStage) { + let psychologicalStageChangeReason = 'Website - Organic start flow'; // Default psystageChangeReason to "Website - Organic start flow" + if(this.req.session.adAttributionString && this.req.session.visitedSiteFromAdAt) { + let thirtyMinutesAgoAt = Date.now() - (1000 * 60 * 30); + // If this user visited the website from an ad, set the psychologicalStageChangeReason to be the adCampaignId stored in their session. + if(this.req.session.visitedSiteFromAdAt > thirtyMinutesAgoAt) { + psychologicalStageChangeReason = this.req.session.adAttributionString; + } + } // Update the psychologicalStageLastChangedAt timestamp if the user's psychological stage psychologicalStageLastChangedAt = Date.now(); sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ @@ -221,7 +235,9 @@ module.exports = { primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, organization: this.req.me.organization, psychologicalStage, - leadSource: 'Website - Sign up', + psychologicalStageChangeReason, + getStartedResponses: questionnaireProgressAsAFormattedString, + contactSource: 'Website - Sign up', }).exec((err)=>{ if(err){ sails.log.warn(`Background task failed: When a user (email: ${this.req.me.emailAddress} submitted a step of the get started questionnaire, a Contact and Account record could not be created/updated in the CRM.`, err); @@ -229,11 +245,6 @@ module.exports = { return; }); }//fi - // TODO: send all other answers to Salesforce (when there are fields for them) - // Set the user's answer to the current step. - questionnaireProgress[currentStep] = formData; - // Clone the questionnaireProgress to prevent any mutations from sending it through the updateOne Waterline method. - let getStartedProgress = _.clone(questionnaireProgress); // Update the user's database model. await User.updateOne({id: userRecord.id}) .set({ @@ -243,7 +254,7 @@ module.exports = { psychologicalStageLastChangedAt, }); // Return the JSON dictionary of form data submitted by this user. - return getStartedProgress; + return {getStartedProgress, psychologicalStage, primaryBuyingSituation}; } diff --git a/website/api/controllers/unsubscribe-from-marketing-emails.js b/website/api/controllers/unsubscribe-from-marketing-emails.js new file mode 100644 index 0000000000..26a9b53ee8 --- /dev/null +++ b/website/api/controllers/unsubscribe-from-marketing-emails.js @@ -0,0 +1,79 @@ +module.exports = { + + + friendlyName: 'Unsubscribe from marketing emails', + + + description: 'Unsubscribes a specified email address from the nurture email automation.', + + + inputs: { + emailAddress: { + type: 'string', + description: 'The email address of the user who wants to unsubscribe from marketing emails.', + required: true, + } + }, + + + exits: { + userNotFound: { + description: 'The provided email address could not be matched to a Fleet user account', + responseType: 'badRequest', + }, + success: { + description: 'The user has opted out of markering emails', + } + }, + + + fn: async function ({emailAddress}) { + + let userRecord = await User.findOne({emailAddress: emailAddress}); + + if(!userRecord){ + throw 'userNotFound'; + } + // Update the user record for this email address to set their nurture email timestamps to 1 + // so they are excluded them from future runs of the deliver-nurture-emails script. + // FUTURE: update the user model to have a subscribedToNurtureEmails attribute. + await User.updateOne({emailAddress: emailAddress}).set({ + stageThreeNurtureEmailSentAt: 1, + stageFourNurtureEmailSentAt: 1, + stageFiveNurtureEmailSentAt: 1, + }); + + // Update the contact record in salesforce for this email address to indicate that they have opted out of marketing emails. + if(sails.config.environment === 'production'){ + require('assert')(sails.config.custom.salesforceIntegrationUsername); + require('assert')(sails.config.custom.salesforceIntegrationPasskey); + + // Log in to Salesforce. + let jsforce = require('jsforce'); + let salesforceConnection = new jsforce.Connection({ + loginUrl : 'https://fleetdm.my.salesforce.com' + }); + await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); + + let existingContactRecord = await salesforceConnection.sobject('Contact') + .findOne({ + Email: emailAddress, + }); + + if(existingContactRecord) { + //If we found an existing contact record in salesforce, update its status to be "Do not contact" + let salesforceContactId = existingContactRecord.Id; + await salesforceConnection.sobject('Contact') + .update({ + Id: salesforceContactId, + Unsubscribed_from_email_contact__c: true,// eslint-disable-line camelcase + }); + } + } + // Redirect the user to the homepage with a #unsubscribe hash link. + return this.res.redirect('/#unsubscribed'); + + } + + +}; diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index c5e2ec892b..7e1f33c5ac 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -22,13 +22,23 @@ module.exports = { } // Get testimonials for the component. let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); + // Default the pagePersonalization to the user's primaryBuyingSituation. + let pagePersonalization = this.req.session.primaryBuyingSituation; + // If a purpose query parameter is set, update the pagePersonalization value. + // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session. + // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation. + if(this.req.param('purpose') === 'it'){ + pagePersonalization = 'eo-it'; + } else if(this.req.param('purpose') === 'security'){ + pagePersonalization = 'eo-security'; + } // Specify an order for the testimonials on this page using the last names of quote authors - let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Harrison Ravazzolo','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; - if(['eo-it', 'mdm'].includes(this.req.session.primaryBuyingSituation)){ - testimonialOrderForThisPage = [ 'Harrison Ravazzolo', 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; - } else if(['eo-security', 'vm'].includes(this.req.session.primaryBuyingSituation)){ + let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; + if(['eo-it', 'mdm'].includes(pagePersonalization)){ + testimonialOrderForThisPage = [ 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; + } else if(['eo-security', 'vm'].includes(pagePersonalization)){ testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar','Alvaro Gutierrez', 'Joe Pistone']; } // Filter the testimonials by product category and the filtered list we built above. @@ -48,6 +58,7 @@ module.exports = { // Respond with view. return { testimonialsForScrollableTweets, + pagePersonalization, }; } diff --git a/website/api/controllers/view-fleetctl-preview.js b/website/api/controllers/view-fleetctl-preview.js index 0712b57acb..aa5d0c5600 100644 --- a/website/api/controllers/view-fleetctl-preview.js +++ b/website/api/controllers/view-fleetctl-preview.js @@ -30,8 +30,25 @@ module.exports = { fn: async function ({start}) { + let trialLicenseKey; + // Check to see if this user has a Fleet premium trial license key. + let userHasTrialLicense = this.req.me.fleetPremiumTrialLicenseKey; + let userHasExpiredTrialLicense = false; + if(userHasTrialLicense) { + if(this.req.me.fleetPremiumTrialLicenseKeyExpiresAt < Date.now()) { + userHasExpiredTrialLicense = true; + } + trialLicenseKey = this.req.me.fleetPremiumTrialLicenseKey; + } else { + trialLicenseKey = ''; + } + // Respond with view. - return {hideNextStepsButtons: start}; + return { + hideNextStepsButtons: start, + trialLicenseKey, + userHasExpiredTrialLicense, + }; } diff --git a/website/api/controllers/view-pricing.js b/website/api/controllers/view-pricing.js index 7d07b6b770..e2b2794d47 100644 --- a/website/api/controllers/view-pricing.js +++ b/website/api/controllers/view-pricing.js @@ -29,7 +29,7 @@ module.exports = { let pricingTable = []; - let pricingTableCategories = ['Deployment', 'Device management', 'Endpoint operations', 'Vulnerability management', 'Integrations', 'Support']; + let pricingTableCategories = ['Devices', 'Deployment', 'Configuration', 'Integrations', 'Support']; for(let category of pricingTableCategories) { // Get all the features in that have a pricingTableFeatures array that contains this category. let featuresInThisCategory = _.filter(pricingTableFeatures, (feature)=>{ @@ -45,7 +45,7 @@ module.exports = { } let pricingTableForSecurity = []; - let categoryOrderForSecurityPricingTable = ['Support', 'Deployment', 'Integrations', 'Endpoint operations', 'Vulnerability management']; + let categoryOrderForSecurityPricingTable = ['Devices', 'Deployment', 'Configuration', 'Integrations', 'Support']; for(let category of categoryOrderForSecurityPricingTable) { // Get all the features in that have a pricingTableFeatures array that contains this category. let featuresInThisCategory = _.filter(pricingTableFeatures, (feature)=>{ @@ -61,7 +61,7 @@ module.exports = { } - let categoryOrderForITPricingTable = [ 'Deployment','Device management', 'Endpoint operations', 'Integrations', 'Support']; + let categoryOrderForITPricingTable = ['Devices', 'Deployment', 'Configuration', 'Integrations', 'Support']; let pricingTableForIt = []; // Sort the IT-focused pricing table from the order of the elements in the categoryOrderForITPricingTable array. for(let category of categoryOrderForITPricingTable) { diff --git a/website/api/controllers/webhooks/receive-from-github.js b/website/api/controllers/webhooks/receive-from-github.js index 20a5dde537..4dcbad029b 100644 --- a/website/api/controllers/webhooks/receive-from-github.js +++ b/website/api/controllers/webhooks/receive-from-github.js @@ -78,7 +78,6 @@ module.exports = { 'sampfluger88', 'ireedy', 'mostlikelee', - 'pacamaster', 'AnthonySnyder8', 'jahzielv', 'getvictor', @@ -89,6 +88,9 @@ module.exports = { 'PezHub', 'SFriendLee', 'ddribeiro', + 'rebeccaui', + 'allenhouchins', + 'harrisonravazzolo', ]; 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-usage-analytics.js b/website/api/controllers/webhooks/receive-usage-analytics.js index 9094c11d79..94f7fec744 100644 --- a/website/api/controllers/webhooks/receive-usage-analytics.js +++ b/website/api/controllers/webhooks/receive-usage-analytics.js @@ -39,6 +39,10 @@ module.exports = { numHostSoftwareInstalledPaths: {type: 'number', defaultsTo: 0}, numSoftwareCPEs: {type: 'number', defaultsTo: 0}, numSoftwareCVEs: {type: 'number', defaultsTo: 0}, + aiFeaturesDisabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsEnabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsConfigured: {type: 'boolean', defaultsTo: false }, + numHostsFleetDesktopEnabled: {type: 'number', defaultsTo: 0 }, }, diff --git a/website/api/helpers/create-license-key.js b/website/api/helpers/create-license-key.js index 26c9b8ad84..18e38d6d6a 100644 --- a/website/api/helpers/create-license-key.js +++ b/website/api/helpers/create-license-key.js @@ -47,7 +47,7 @@ module.exports = { let jwt = require('jsonwebtoken'); - let expirationTimestampInSeconds = (expiresAt / 1000); + let expirationTimestampInSeconds = Math.floor(expiresAt / 1000); let token = jwt.sign( { iss: 'Fleet Device Management Inc.', diff --git a/website/api/helpers/get-extended-osquery-schema.js b/website/api/helpers/get-extended-osquery-schema.js index d19b090b7b..1a697a5847 100644 --- a/website/api/helpers/get-extended-osquery-schema.js +++ b/website/api/helpers/get-extended-osquery-schema.js @@ -11,6 +11,10 @@ module.exports = { type: 'boolean', defaultsTo: false, description: 'Whether or not to include a lastModifiedAt value for each table.', + }, + githubAccessToken: { + type: 'string', + description: 'A github token used to authenticate requests to the GitHub API' } }, @@ -25,11 +29,10 @@ module.exports = { }, - fn: async function ({includeLastModifiedAtValue}) { + fn: async function ({includeLastModifiedAtValue, githubAccessToken}) { let path = require('path'); let YAML = require('yaml'); let util = require('util'); - let topLvlRepoPath = path.resolve(sails.config.appPath, '../'); require('assert')(sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation, 'Please set sails.config.custom.sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation to the version of osquery to use, for example \'5.8.1\'.'); let VERSION_OF_OSQUERY_SCHEMA_TO_USE = sails.config.custom.versionOfOsquerySchemaToUseWhenGeneratingDocumentation; @@ -40,6 +43,14 @@ module.exports = { let rawOsqueryTablesLastModifiedAt; if(includeLastModifiedAtValue) { // If we're including a lastModifiedAt value for schema tables, we'll send a request to the GitHub API to get a timestamp of when the last commit + let baseHeadersForGithubRequests = { + 'User-Agent': 'fleet-schema-builder', + 'Accept': 'application/vnd.github.v3+json', + }; + // If a GitHub access token was provided, add it to the headers. + if(githubAccessToken){ + baseHeadersForGithubRequests['Authorization'] = `token ${githubAccessToken}`; + } let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits url: 'https://api.github.com/repos/osquery/osquery-site/commits', data: { @@ -47,10 +58,7 @@ module.exports = { page: 1, per_page: 1,//eslint-disable-line camelcase }, - headers: { - 'User-Agent': 'fleet-schema-builder', - 'Accept': 'application/vnd.github.v3+json', - }, + headers: baseHeadersForGithubRequests }).intercept((err)=>{ return new Error(`When trying to send a request to GitHub get a timestamp of the last commit to the osqeury schema JSON, an error occurred. Full error: ${util.inspect(err)}`); }); diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index f4b56ca88a..d1c4d385b1 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -252,6 +252,39 @@ module.exports = { if (emailDomain && employer.emailDomain && employer.emailDomain !== emailDomain) { sails.log.info(`Unexpected result when enriching: Email domain inferred from matched organization website (${employer.emailDomain}) does not equal the parsed email domain (${emailDomain}) that was derived from the provided "emailAddress" (${emailAddress})`); }//fi + + // Use OpenAI to try and enrich some additional data, if it's missing. + if (!employer.numberOfEmployees) { + if (!sails.config.custom.openAiSecret) { + throw new Error('sails.config.custom.openAiSecret not set.'); + }//• + + let prompt = `How many employees does the organization who owns ${emailDomain} have? + + Please respond in this form (but instead of 0, put the number of employees, as an integer: + { + "employees": 0 + }`; + let BASE_MODEL = 'gpt-4o';// The base model to use. https://platform.openai.com/docs/models/gpt-4 + // [?] API: https://platform.openai.com/docs/api-reference/chat/create + let openAiResponse = await sails.helpers.http.post('https://api.openai.com/v1/chat/completions', { + model: BASE_MODEL, + messages: [ { role: 'user', content: prompt } ],// // https://platform.openai.com/docs/guides/chat/introduction + temperature: 0.7, + max_tokens: 256//eslint-disable-line camelcase + }, { + Authorization: `Bearer ${sails.config.custom.openAiSecret}` + }) + .tolerate((unusedErr)=>{}); + + if (openAiResponse) { + try { + employer.numberOfEmployees = JSON.parse(openAiResponse.choices[0].message.content).employees; + } catch (unusedErr) { + employer.numberOfEmployees = 1; + } + }//fi + }//fi }//fi }//fi diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js index aa13206c48..05e9b29c95 100644 --- a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js @@ -30,12 +30,16 @@ module.exports = { '6 - Has team buy-in' ] }, + psychologicalStageChangeReason: { + type: 'string', + example: 'Website - Organic start flow' + }, // For new leads. leadDescription: { type: 'string', description: 'A description of what this lead is about; e.g. a contact form message, or the size of t-shirt being requested.' }, - leadSource: { + contactSource: { type: 'string', required: true, isIn: [ @@ -58,7 +62,7 @@ module.exports = { - fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, leadSource, leadDescription, numberOfHosts}) { + fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, leadDescription, numberOfHosts}) { if(sails.config.environment !== 'production') { sails.log('Skipping Salesforce integration...'); return; @@ -72,7 +76,8 @@ module.exports = { linkedinUrl, primaryBuyingSituation, psychologicalStage, - leadSource, + psychologicalStageChangeReason, + contactSource, description: leadDescription, }); @@ -80,7 +85,7 @@ module.exports = { salesforceContactId: recordIds.salesforceContactId, salesforceAccountId: recordIds.salesforceAccountId, leadDescription, - leadSource, + leadSource: contactSource, numberOfHosts, }); diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account.js b/website/api/helpers/salesforce/update-or-create-contact-and-account.js index aea76fd28c..41ebd76efe 100644 --- a/website/api/helpers/salesforce/update-or-create-contact-and-account.js +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account.js @@ -30,13 +30,21 @@ module.exports = { '6 - Has team buy-in' ] }, - leadSource: { + psychologicalStageChangeReason: { + type: 'string', + example: 'Website - Organic start flow' + }, + contactSource: { type: 'string', isIn: [ 'Website - Contact forms', 'Website - Sign up', ], }, + getStartedResponses: { + type: 'string', + } + }, @@ -52,7 +60,7 @@ module.exports = { }, - fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, leadSource, description}) { + fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, description, getStartedResponses}) { // Return undefined if we're not running in a production environment. if(sails.config.environment !== 'production') { sails.log.verbose('Skipping Salesforce integration...'); @@ -96,9 +104,15 @@ module.exports = { if(psychologicalStage) { valuesToSet.Stage__c = psychologicalStage;// eslint-disable-line camelcase } + if(getStartedResponses) { + valuesToSet.Website_questionnaire_answers__c = getStartedResponses;// eslint-disable-line camelcase + } if(description) { valuesToSet.Description = description; } + if(psychologicalStageChangeReason) { + valuesToSet.Psystage_change_reason__c = psychologicalStageChangeReason;// eslint-disable-line camelcase + } let existingContactRecord; // Search for an existing Contact record using the provided email address or linkedIn profile URL. @@ -119,6 +133,21 @@ module.exports = { if(description && existingContactRecord.Description) { valuesToSet.Description = existingContactRecord.Description + '\n' + description; } + // Check the existing contact record's psychologicalStage. + if(psychologicalStage) { + let recordsCurrentPsyStage = existingContactRecord.Stage__c; + // Because each psychological stage starts with a number, we'll get the first character in the record's current psychological stage and the new psychological stage to make comparison easier. + let psyStageStageNumberToChangeTo = Number(psychologicalStage[0]); + let recordsCurrentPsyStageNumber = Number(recordsCurrentPsyStage[0]); + if(psyStageStageNumberToChangeTo < recordsCurrentPsyStageNumber) { + // If a psychological stage regression is caused by anything other than the start flow, remove the updated value. + // This is done to prevent automated psyStage regressions caused by users taking other action on the website. (e.g, Booking a meeting or requesting Fleet swag.) + if(psychologicalStageChangeReason && psychologicalStageChangeReason !== 'Website - Organic start flow') { + delete valuesToSet.Stage__c; + delete valuesToSet.Psystage_change_reason__c; + } + } + } // console.log(`Exisitng contact found! ${existingContactRecord.Id}`); // If we found an existing contact, we'll update it with the information provided. salesforceContactId = existingContactRecord.Id; @@ -179,7 +208,7 @@ module.exports = { // Create a timestamp to use for the new account's assigned date. let today = new Date(); let nowOn = today.toISOString().replace('Z', '+0000'); - + require('assert')(typeof enrichmentData.employer.numberOfEmployees === 'number'); let newAccountRecord = await salesforceConnection.sobject('Account') .create({ Account_Assigned_date__c: nowOn,// eslint-disable-line camelcase @@ -198,9 +227,9 @@ module.exports = { // console.log('New account created!', salesforceAccountId); }//fi - // Only add leadSource to valuesToSet if we're creating a new contact record. - if(leadSource) { - valuesToSet.LeadSource = leadSource; + // Only add contactSource to valuesToSet if we're creating a new contact record. + if(contactSource) { + valuesToSet.Contact_source__c = contactSource;// eslint-disable-line camelcase } // console.log(`creating new Contact record.`) // Create a new Contact record for this person. diff --git a/website/api/helpers/send-template-email.js b/website/api/helpers/send-template-email.js index 4679719318..ab2f780002 100644 --- a/website/api/helpers/send-template-email.js +++ b/website/api/helpers/send-template-email.js @@ -55,6 +55,18 @@ module.exports = { example: 'Nola Thacker', }, + replyTo: { + description: 'The reply to email address.', + example: { + emailAddress: 'anne.m.martin@example.com', + name: 'Anne M. Martin' + }, + type: { + emailAddress: 'string', + name: 'string', + } + }, + subject: { description: 'The subject of the email.', example: 'Hello there.', @@ -122,7 +134,7 @@ module.exports = { }, - fn: async function({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments}) { + fn: async function({template, templateData, to, toName, subject, from, fromName, layout, replyTo, ensureAck, bcc, attachments}) { var path = require('path'); var url = require('url'); @@ -247,6 +259,7 @@ module.exports = { subject: subjectLinePrefix+subject, from: from, fromName: fromName, + replyTo: replyTo, attachments }; diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js index e82a6ba8ab..d2bd549ddb 100644 --- a/website/api/hooks/custom/index.js +++ b/website/api/hooks/custom/index.js @@ -147,6 +147,17 @@ will be disabled and/or hidden in the UI. res.locals.me = undefined; }//fi + // Check for query parameters set by ad clicks. + // This is used to track the reason behind a psychological stage change. + // If the user performs any action that causes a stage change + // within 30 minutes of visiting the website from an ad, their psychological + // stage change will be attributed to the ad campaign that brought them here. + if(req.param('utm_source') && req.param('creative_id') && req.param('campaign_id')){ + req.session.adAttributionString = `${req.param('utm_source')} ads - ${req.param('campaign_id')} - ${_.trim(req.param('creative_id'), '?')}`;// Trim questionmarks from the end of creative_id parameters. + // Example adAttributionString: Linkedin - 1245983829 - 41u3985237 + req.session.visitedSiteFromAdAt = Date.now(); + } + // Check for website personalization parameter, and if valid, absorb it in the session. // (This makes the experience simpler and less confusing for people, prioritizing showing things that matter for them) // [?] https://en.wikipedia.org/wiki/UTM_parameters @@ -289,8 +300,8 @@ will be disabled and/or hidden in the UI. // FUTURE: Only show this CTA to users who are below psyStage 6. // > The code below is so we don't bother users who have completed the questionnaire - // Determine if this user should see the CTA to bring them to the /start questionnaire using the user's last submitted questionnaire answer. - res.locals.showStartCta = !['how-many-hosts','will-you-be-self-hosting','managed-cloud-for-growing-deployments','self-hosted-deploy', 'whats-left-to-get-you-set-up'].includes(req.me.lastSubmittedGetStartedQuestionnaireStep); + // Show this logged-in user a CTA to bring them to the /start questionnaire if they do not have billing information saved. + res.locals.showStartCta = !req.me.hasBillingCard; // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * // // If an expandCtaAt timestamp is set in the user's sesssion, check the value to see if we should expand the CTA. diff --git a/website/api/models/HistoricalUsageSnapshot.js b/website/api/models/HistoricalUsageSnapshot.js index 5481d42e53..1f14b9b291 100644 --- a/website/api/models/HistoricalUsageSnapshot.js +++ b/website/api/models/HistoricalUsageSnapshot.js @@ -43,6 +43,10 @@ module.exports = { numHostSoftwareInstalledPaths: {required: true, type: 'number'}, numSoftwareCPEs: {required: true, type: 'number'}, numSoftwareCVEs: {required: true, type: 'number'}, + aiFeaturesDisabled: {required: true, type: 'boolean'}, + maintenanceWindowsEnabled: {required: true, type: 'boolean'}, + maintenanceWindowsConfigured: {required: true, type: 'boolean'}, + numHostsFleetDesktopEnabled: {required: true, type: 'number'}, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/website/api/models/User.js b/website/api/models/User.js index 72b603338d..7f325e3ffa 100644 --- a/website/api/models/User.js +++ b/website/api/models/User.js @@ -248,19 +248,28 @@ without necessarily having a billing card.` stageThreeNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 3 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 3 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, stageFourNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 4 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 4 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, stageFiveNurtureEmailSentAt: { type: 'number', - description: 'A JS timestamp of when the stage 5 nurture email was sent to the user.' + description: 'A JS timestamp of when the stage 5 nurture email was sent to the user, or 1 if the user is unsubscribed from automated emails.', }, + fleetPremiumTrialLicenseKey: { + type: 'string', + description: 'A Fleet Premium license key that was generated for this user when they progressed through the get started questionnaire.', + }, + + fleetPremiumTrialLicenseKeyExpiresAt: { + type: 'number', + description: 'A JS timestamp of when this user\'s Fleet Premium trial license key expires.', + }, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/website/assets/dependencies/docsearch.min.js b/website/assets/dependencies/docsearch.min.js index 336fc3e303..1851df2b63 100644 --- a/website/assets/dependencies/docsearch.min.js +++ b/website/assets/dependencies/docsearch.min.js @@ -1,4 +1,3 @@ - -/*! @docsearch/js 3.5.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).docsearch=t()}(this,(function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function c(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(e){return function(e){if(Array.isArray(e))return l(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||u(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(e,t){if(e){if("string"==typeof e)return l(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(e,t):void 0}}function l(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n3)for(n=[n],i=3;i0?O(m.type,m.props,m.key,null,m.__v):m)){if(m.__=n,m.__b=n.__b+1,null===(p=_[s])||p&&m.key==p.key&&m.type===p.type)_[s]=void 0;else for(f=0;f3)for(n=[n],i=3;i=n.__.length&&n.__.push({}),n.__[e]}function ne(e){return $=1,re(pe,e)}function re(e,t,n){var r=te(K++,2);return r.t=e,r.__c||(r.__=[n?n(t):pe(void 0,t),function(e){var t=r.t(r.__[0],e);r.__[0]!==t&&(r.__=[t,r.__[1]],r.__c.setState({}))}],r.__c=z),r.__}function oe(e,t){var n=te(K++,3);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__H.__h.push(n))}function ie(e,t){var n=te(K++,4);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__h.push(n))}function ce(e,t){var n=te(K++,7);return fe(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function ae(){Q.forEach((function(e){if(e.__P)try{e.__H.__h.forEach(le),e.__H.__h.forEach(se),e.__H.__h=[]}catch(t){e.__H.__h=[],s.__e(t,e.__v)}})),Q=[]}s.__b=function(e){z=null,Z&&Z(e)},s.__r=function(e){Y&&Y(e),K=0;var t=(z=e.__c).__H;t&&(t.__h.forEach(le),t.__h.forEach(se),t.__h=[])},s.diffed=function(e){G&&G(e);var t=e.__c;t&&t.__H&&t.__H.__h.length&&(1!==Q.push(t)&&J===s.requestAnimationFrame||((J=s.requestAnimationFrame)||function(e){var t,n=function(){clearTimeout(r),ue&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,100);ue&&(t=requestAnimationFrame(n))})(ae)),z=void 0},s.__c=function(e,t){t.some((function(e){try{e.__h.forEach(le),e.__h=e.__h.filter((function(e){return!e.__||se(e)}))}catch(n){t.some((function(e){e.__h&&(e.__h=[])})),t=[],s.__e(n,e.__v)}})),X&&X(e,t)},s.unmount=function(e){ee&&ee(e);var t=e.__c;if(t&&t.__H)try{t.__H.__.forEach(le)}catch(e){s.__e(e,t.__v)}};var ue="function"==typeof requestAnimationFrame;function le(e){var t=z;"function"==typeof e.__c&&e.__c(),z=t}function se(e){var t=z;e.__c=e.__(),z=t}function fe(e,t){return!e||e.length!==t.length||t.some((function(t,n){return t!==e[n]}))}function pe(e,t){return"function"==typeof t?t(e):t}function me(e,t){for(var n in t)e[n]=t[n];return e}function ve(e,t){for(var n in e)if("__source"!==n&&!(n in t))return!0;for(var r in t)if("__source"!==r&&e[r]!==t[r])return!0;return!1}function de(e){this.props=e}(de.prototype=new j).isPureReactComponent=!0,de.prototype.shouldComponentUpdate=function(e,t){return ve(this.props,e)||ve(this.state,t)};var he=s.__b;s.__b=function(e){e.type&&e.type.__f&&e.ref&&(e.props.ref=e.ref,e.ref=null),he&&he(e)};var ye="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.forward_ref")||3911;var be=function(e,t){return null==e?null:A(A(e).map(t))},_e={map:be,forEach:be,count:function(e){return e?A(e).length:0},only:function(e){var t=A(e);if(1!==t.length)throw"Children.only";return t[0]},toArray:A},ge=s.__e;function Oe(){this.__u=0,this.t=null,this.__b=null}function Se(e){var t=e.__.__c;return t&&t.__e&&t.__e(e)}function je(){this.u=null,this.o=null}s.__e=function(e,t,n){if(e.then)for(var r,o=t;o=o.__;)if((r=o.__c)&&r.__c)return null==t.__e&&(t.__e=n.__e,t.__k=n.__k),r.__c(e,t);ge(e,t,n)},(Oe.prototype=new j).__c=function(e,t){var n=t.__c,r=this;null==r.t&&(r.t=[]),r.t.push(n);var o=Se(r.__v),i=!1,c=function(){i||(i=!0,n.componentWillUnmount=n.__c,o?o(a):a())};n.__c=n.componentWillUnmount,n.componentWillUnmount=function(){c(),n.__c&&n.__c()};var a=function(){if(!--r.__u){if(r.state.__e){var e=r.state.__e;r.__v.__k[0]=function e(t,n,r){return t&&(t.__v=null,t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)})),t.__c&&t.__c.__P===n&&(t.__e&&r.insertBefore(t.__e,t.__d),t.__c.__e=!0,t.__c.__P=r)),t}(e,e.__c.__P,e.__c.__O)}var t;for(r.setState({__e:r.__b=null});t=r.t.pop();)t.forceUpdate()}},u=!0===t.__h;r.__u++||u||r.setState({__e:r.__b=r.__v.__k[0]}),e.then(c,c)},Oe.prototype.componentWillUnmount=function(){this.t=[]},Oe.prototype.render=function(e,t){if(this.__b){if(this.__v.__k){var n=document.createElement("div"),r=this.__v.__k[0].__c;this.__v.__k[0]=function e(t,n,r){return t&&(t.__c&&t.__c.__H&&(t.__c.__H.__.forEach((function(e){"function"==typeof e.__c&&e.__c()})),t.__c.__H=null),null!=(t=me({},t)).__c&&(t.__c.__P===r&&(t.__c.__P=n),t.__c=null),t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)}))),t}(this.__b,n,r.__O=r.__P)}this.__b=null}var o=t.__e&&g(S,null,e.fallback);return o&&(o.__h=null),[g(S,null,t.__e?null:e.children),o]};var we=function(e,t,n){if(++n[1]===n[0]&&e.o.delete(t),e.props.revealOrder&&("t"!==e.props.revealOrder[0]||!e.o.size))for(n=e.u;n;){for(;n.length>3;)n.pop()();if(n[1]>>1,1),t.i.removeChild(e)}}),B(g(Ee,{context:t.context},e.__v),t.l)):t.l&&t.componentWillUnmount()}function Ie(e,t){return g(Pe,{__v:e,i:t})}(je.prototype=new j).__e=function(e){var t=this,n=Se(t.__v),r=t.o.get(e);return r[0]++,function(o){var i=function(){t.props.revealOrder?(r.push(o),we(t,e,r)):o()};n?n(i):i()}},je.prototype.render=function(e){this.u=null,this.o=new Map;var t=A(e.children);e.revealOrder&&"b"===e.revealOrder[0]&&t.reverse();for(var n=t.length;n--;)this.o.set(t[n],this.u=[1,0,this.u]);return e.children},je.prototype.componentDidUpdate=je.prototype.componentDidMount=function(){var e=this;this.o.forEach((function(t,n){we(e,n,t)}))};var De="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,ke=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,Ae=function(e){return("undefined"!=typeof Symbol&&"symbol"==n(Symbol())?/fil|che|rad/i:/fil|che|ra/i).test(e)};function Ce(e,t,n){return null==t.__k&&(t.textContent=""),B(e,t),"function"==typeof n&&n(),e?e.__c:null}j.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach((function(e){Object.defineProperty(j.prototype,e,{configurable:!0,get:function(){return this["UNSAFE_"+e]},set:function(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,value:t})}})}));var xe=s.event;function Ne(){}function Te(){return this.cancelBubble}function Re(){return this.defaultPrevented}s.event=function(e){return xe&&(e=xe(e)),e.persist=Ne,e.isPropagationStopped=Te,e.isDefaultPrevented=Re,e.nativeEvent=e};var qe,Le={configurable:!0,get:function(){return this.class}},Me=s.vnode;s.vnode=function(e){var t=e.type,n=e.props,r=n;if("string"==typeof t){for(var o in r={},n){var i=n[o];"value"===o&&"defaultValue"in n&&null==i||("defaultValue"===o&&"value"in n&&null==n.value?o="value":"download"===o&&!0===i?i="":/ondoubleclick/i.test(o)?o="ondblclick":/^onchange(textarea|input)/i.test(o+t)&&!Ae(n.type)?o="oninput":/^on(Ani|Tra|Tou|BeforeInp)/.test(o)?o=o.toLowerCase():ke.test(o)?o=o.replace(/[A-Z0-9]/,"-$&").toLowerCase():null===i&&(i=void 0),r[o]=i)}"select"==t&&r.multiple&&Array.isArray(r.value)&&(r.value=A(n.children).forEach((function(e){e.props.selected=-1!=r.value.indexOf(e.props.value)}))),"select"==t&&null!=r.defaultValue&&(r.value=A(n.children).forEach((function(e){e.props.selected=r.multiple?-1!=r.defaultValue.indexOf(e.props.value):r.defaultValue==e.props.value}))),e.props=r}t&&n.class!=n.className&&(Le.enumerable="className"in n,null!=n.className&&(r.class=n.className),Object.defineProperty(r,"className",Le)),e.$$typeof=De,Me&&Me(e)};var He=s.__r;s.__r=function(e){He&&He(e),qe=e.__c};var Ue={ReactCurrentDispatcher:{current:{readContext:function(e){return qe.__n[e.__c].props.value}}}};"object"==("undefined"==typeof performance?"undefined":n(performance))&&"function"==typeof performance.now&&performance.now.bind(performance);function Fe(e){return!!e&&e.$$typeof===De}var Be={useState:ne,useReducer:re,useEffect:oe,useLayoutEffect:ie,useRef:function(e){return $=5,ce((function(){return{current:e}}),[])},useImperativeHandle:function(e,t,n){$=6,ie((function(){"function"==typeof e?e(t()):e&&(e.current=t())}),null==n?n:n.concat(e))},useMemo:ce,useCallback:function(e,t){return $=8,ce((function(){return e}),t)},useContext:function(e){var t=z.context[e.__c],n=te(K++,9);return n.__c=e,t?(null==n.__&&(n.__=!0,t.sub(z)),t.props.value):e.__},useDebugValue:function(e,t){s.useDebugValue&&s.useDebugValue(t?t(e):e)},version:"16.8.0",Children:_e,render:Ce,hydrate:function(e,t,n){return V(e,t),"function"==typeof n&&n(),e?e.__c:null},unmountComponentAtNode:function(e){return!!e.__k&&(B(null,e),!0)},createPortal:Ie,createElement:g,createContext:function(e,t){var n={__c:t="__cC"+v++,__:e,Consumer:function(e,t){return e.children(t)},Provider:function(e){var n,r;return this.getChildContext||(n=[],(r={})[t]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&n.some(P)},this.sub=function(e){n.push(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.splice(n.indexOf(e),1),t&&t.call(e)}}),e.children}};return n.Provider.__=n.Consumer.contextType=n},createFactory:function(e){return g.bind(null,e)},cloneElement:function(e){return Fe(e)?W.apply(null,arguments):e},createRef:function(){return{current:null}},Fragment:S,isValidElement:Fe,findDOMNode:function(e){return e&&(e.base||1===e.nodeType&&e)||null},Component:j,PureComponent:de,memo:function(e,t){function n(e){var n=this.props.ref,r=n==e.ref;return!r&&n&&(n.call?n(null):n.current=null),t?!t(this.props,e)||!r:ve(this.props,e)}function r(t){return this.shouldComponentUpdate=n,g(e,t)}return r.displayName="Memo("+(e.displayName||e.name)+")",r.prototype.isReactComponent=!0,r.__f=!0,r},forwardRef:function(e){function t(t,r){var o=me({},t);return delete o.ref,e(o,(r=t.ref||r)&&("object"!=n(r)||"current"in r)?r:null)}return t.$$typeof=ye,t.render=t,t.prototype.isReactComponent=t.__f=!0,t.displayName="ForwardRef("+(e.displayName||e.name)+")",t},unstable_batchedUpdates:function(e,t){return e(t)},StrictMode:S,Suspense:Oe,SuspenseList:je,lazy:function(e){var t,n,r;function o(o){if(t||(t=e()).then((function(e){n=e.default||e}),(function(e){r=e})),r)throw r;if(!n)throw t;return g(n,o)}return o.displayName="Lazy",o.__f=!0,o},__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:Ue};function Ve(){return Be.createElement("svg",{width:"15",height:"15",className:"DocSearch-Control-Key-Icon"},Be.createElement("path",{d:"M4.505 4.496h2M5.505 5.496v5M8.216 4.496l.055 5.993M10 7.5c.333.333.5.667.5 1v2M12.326 4.5v5.996M8.384 4.496c1.674 0 2.116 0 2.116 1.5s-.442 1.5-2.116 1.5M3.205 9.303c-.09.448-.277 1.21-1.241 1.203C1 10.5.5 9.513.5 8V7c0-1.57.5-2.5 1.464-2.494.964.006 1.134.598 1.24 1.342M12.553 10.5h1.953",strokeWidth:"1.2",stroke:"currentColor",fill:"none",strokeLinecap:"square"}))}function We(){return Be.createElement("svg",{width:"20",height:"20",className:"DocSearch-Search-Icon",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}var Ke=["translations"];function ze(){return ze=Object.assign||function(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var Ze="Ctrl";var Ye=Be.forwardRef((function(e,t){var n=e.translations,r=void 0===n?{}:n,o=Qe(e,Ke),i=r.buttonText,c=void 0===i?"Search":i,a=r.buttonAriaLabel,u=void 0===a?"Search":a,l=Je(ne(null),2),s=l[0],f=l[1];return oe((function(){"undefined"!=typeof navigator&&(/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)?f("⌘"):f(Ze))}),[]),Be.createElement("button",ze({type:"button",className:"DocSearch DocSearch-Button","aria-label":u},o,{ref:t}),Be.createElement("span",{className:"DocSearch-Button-Container"},Be.createElement(We,null),Be.createElement("span",{className:"DocSearch-Button-Placeholder"},c)),Be.createElement("span",{className:"DocSearch-Button-Keys"},null!==s&&Be.createElement(Be.Fragment,null,Be.createElement("kbd",{className:"DocSearch-Button-Key"},s===Ze?Be.createElement(Ve,null):s),Be.createElement("kbd",{className:"DocSearch-Button-Key"},"K"))))}));function Ge(e,t){var n=void 0;return function(){for(var r=arguments.length,o=new Array(r),i=0;ie.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function dt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function ht(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:20,n=[],r=0;r=3||2===n&&r>=4||1===n&&r>=10);function i(t,n,r){if(o&&void 0!==r){var i=r[0].__autocomplete_algoliaCredentials,c={"X-Algolia-Application-Id":i.appId,"X-Algolia-API-Key":i.apiKey};e.apply(void 0,[t].concat(pt(n),[{headers:c}]))}else e.apply(void 0,[t].concat(pt(n)))}return{init:function(t,n){e("init",{appId:t,apiKey:n})},setUserToken:function(t){e("setUserToken",t)},clickedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDsAfterSearch",_t(t),t[0].items)},clickedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDs",_t(t),t[0].items)},clickedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["clickedFilters"].concat(n))},convertedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDsAfterSearch",_t(t),t[0].items)},convertedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDs",_t(t),t[0].items)},convertedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["convertedFilters"].concat(n))},viewedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&t.reduce((function(e,t){var n=t.items,r=vt(t,st);return[].concat(pt(e),pt(bt(ht(ht({},r),{},{objectIDs:(null==n?void 0:n.map((function(e){return e.objectID})))||r.objectIDs})).map((function(e){return{items:n,payload:e}}))))}),[]).forEach((function(e){var t=e.items;return i("viewedObjectIDs",[e.payload],t)}))},viewedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["viewedFilters"].concat(n))}}}function Ot(e){var t=e.items.reduce((function(e,t){var n;return e[t.__autocomplete_indexName]=(null!==(n=e[t.__autocomplete_indexName])&&void 0!==n?n:[]).concat(t),e}),{});return Object.keys(t).map((function(e){return{index:e,items:t[e],algoliaSource:["autocomplete"]}}))}function St(e){return e.objectID&&e.__autocomplete_indexName&&e.__autocomplete_queryID}function jt(e){return jt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},jt(e)}function wt(e){return function(e){if(Array.isArray(e))return Et(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return Et(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Et(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Et(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&Ct({onItemsChange:r,items:n,insights:a,state:t}))}}),0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(e){var t=e.setContext,n=e.onSelect,r=e.onActive;c("addAlgoliaAgent","insights-plugin"),t({algoliaInsightsPlugin:{__algoliaSearchParameters:{clickAnalytics:!0},insights:a}}),n((function(e){var t=e.item,n=e.state,r=e.event;St(t)&&o({state:n,event:r,insights:a,item:t,insightsEvents:[It({eventName:"Item Selected"},ct({item:t,items:u.current}))]})})),r((function(e){var t=e.item,n=e.state,r=e.event;St(t)&&i({state:n,event:r,insights:a,item:t,insightsEvents:[It({eventName:"Item Active"},ct({item:t,items:u.current}))]})}))},onStateChange:function(e){var t=e.state;l({state:t})},__autocomplete_pluginOptions:e}}function Nt(e,t){var n=t;return{then:function(t,r){return Nt(e.then(Rt(t,n,e),Rt(r,n,e)),n)},catch:function(t){return Nt(e.catch(Rt(t,n,e)),n)},finally:function(t){return t&&n.onCancelList.push(t),Nt(e.finally(Rt(t&&function(){return n.onCancelList=[],t()},n,e)),n)},cancel:function(){n.isCanceled=!0;var e=n.onCancelList;n.onCancelList=[],e.forEach((function(e){e()}))},isCanceled:function(){return!0===n.isCanceled}}}function Tt(e){return Nt(e,{isCanceled:!1,onCancelList:[]})}function Rt(e,t,n){return e?function(n){return t.isCanceled?n:e(n)}:n}function qt(e,t,n,r){if(!n)return null;if(e<0&&(null===t||null!==r&&0===t))return n+e;var o=(null===t?-1:t)+e;return o<=-1||o>=n?null===r?null:0:o}function Lt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Mt(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n0},reshape:function(e){return e.sources}},e),{},{id:null!==(n=e.id)&&void 0!==n?n:"autocomplete-".concat(et++),plugins:o,initialState:tn({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},e.initialState),onStateChange:function(t){var n;null===(n=e.onStateChange)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onStateChange)||void 0===n?void 0:n.call(e,t)}))},onSubmit:function(t){var n;null===(n=e.onSubmit)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onSubmit)||void 0===n?void 0:n.call(e,t)}))},onReset:function(t){var n;null===(n=e.onReset)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onReset)||void 0===n?void 0:n.call(e,t)}))},getSources:function(n){return Promise.all([].concat(Gt(o.map((function(e){return e.getSources}))),[e.getSources]).filter(Boolean).map((function(e){return function(e,t){var n=[];return Promise.resolve(e(t)).then((function(e){return Promise.all(e.filter((function(e){return Boolean(e)})).map((function(e){if(e.sourceId,n.includes(e.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(e.sourceId)," is not unique."));n.push(e.sourceId);var t={getItemInputValue:function(e){return e.state.query},getItemUrl:function(){},onSelect:function(e){(0,e.setIsOpen)(!1)},onActive:ot,onResolve:ot};Object.keys(t).forEach((function(e){t[e].__default=!0}));var r=Mt(Mt({},t),e);return Promise.resolve(r)})))}))}(e,n)}))).then((function(e){return Xe(e)})).then((function(e){return e.map((function(e){return tn(tn({},e),{},{onSelect:function(n){e.onSelect(n),t.forEach((function(e){var t;return null===(t=e.onSelect)||void 0===t?void 0:t.call(e,n)}))},onActive:function(n){e.onActive(n),t.forEach((function(e){var t;return null===(t=e.onActive)||void 0===t?void 0:t.call(e,n)}))},onResolve:function(n){e.onResolve(n),t.forEach((function(e){var t;return null===(t=e.onResolve)||void 0===t?void 0:t.call(e,n)}))}})}))}))},navigator:tn({navigate:function(e){var t=e.itemUrl;r.location.assign(t)},navigateNewTab:function(e){var t=e.itemUrl,n=r.open(t,"_blank","noopener");null==n||n.focus()},navigateNewWindow:function(e){var t=e.itemUrl;r.open(t,"_blank","noopener")}},e.navigator)})}function on(e){return on="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},on(e)}function cn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function an(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var En,Pn,In,Dn=null,kn=(En=-1,Pn=-1,In=void 0,function(e){var t=++En;return Promise.resolve(e).then((function(e){return In&&t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Ln(e){return Ln="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Ln(e)}var Mn=["props","refresh","store"],Hn=["inputElement","formElement","panelElement"],Un=["inputElement"],Fn=["inputElement","maxLength"],Bn=["sourceIndex"],Vn=["sourceIndex"],Wn=["item","source","sourceIndex"];function Kn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function zn(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Qn(e){var t=e.props,n=e.refresh,r=e.store,o=$n(e,Mn),i=function(e,t){return void 0!==t?"".concat(e,"-").concat(t):e};return{getEnvironmentProps:function(e){var n=e.inputElement,o=e.formElement,i=e.panelElement;function c(e){!r.getState().isOpen&&r.pendingRequests.isEmpty()||e.target===n||!1===[o,i].some((function(t){return n=t,r=e.target,n===r||n.contains(r);var n,r}))&&(r.dispatch("blur",null),t.debug||r.pendingRequests.cancelAll())}return zn({onTouchStart:c,onMouseDown:c,onTouchMove:function(e){!1!==r.getState().isOpen&&n===t.environment.document.activeElement&&e.target!==n&&n.blur()}},$n(e,Hn))},getRootProps:function(e){return zn({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-owns":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label")},e)},getFormProps:function(e){e.inputElement;return zn({action:"",noValidate:!0,role:"search",onSubmit:function(i){var c;i.preventDefault(),t.onSubmit(zn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),null===(c=e.inputElement)||void 0===c||c.blur()},onReset:function(i){var c;i.preventDefault(),t.onReset(zn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),null===(c=e.inputElement)||void 0===c||c.focus()}},$n(e,Un))},getLabelProps:function(e){var n=e||{},r=n.sourceIndex,o=$n(n,Bn);return zn({htmlFor:"".concat(i(t.id,r),"-input"),id:"".concat(i(t.id,r),"-label")},o)},getInputProps:function(e){var i;function c(e){(t.openOnFocus||Boolean(r.getState().query))&&An(zn({event:e,props:t,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var a=e||{},u=(a.inputElement,a.maxLength),l=void 0===u?512:u,s=$n(a,Fn),f=Ft(r.getState()),p=function(e){return Boolean(e&&e.match(Bt))}((null===(i=t.environment.navigator)||void 0===i?void 0:i.userAgent)||""),m=null!=f&&f.itemUrl&&!p?"go":"search";return zn({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&null!==r.getState().activeItemId?"".concat(t.id,"-item-").concat(r.getState().activeItemId):void 0,"aria-controls":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label"),value:r.getState().completion||r.getState().query,id:"".concat(t.id,"-input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:m,spellCheck:"false",autoFocus:t.autoFocus,placeholder:t.placeholder,maxLength:l,type:"search",onChange:function(e){An(zn({event:e,props:t,query:e.currentTarget.value.slice(0,l),refresh:n,store:r},o))},onKeyDown:function(e){!function(e){var t=e.event,n=e.props,r=e.refresh,o=e.store,i=qn(e,xn);if("ArrowUp"===t.key||"ArrowDown"===t.key){var c=function(){var e=n.environment.document.getElementById("".concat(n.id,"-item-").concat(o.getState().activeItemId));e&&(e.scrollIntoViewIfNeeded?e.scrollIntoViewIfNeeded(!1):e.scrollIntoView(!1))},a=function(){var e=Ft(o.getState());if(null!==o.getState().activeItemId&&e){var n=e.item,c=e.itemInputValue,a=e.itemUrl,u=e.source;u.onActive(Tn({event:t,item:n,itemInputValue:c,itemUrl:a,refresh:r,source:u,state:o.getState()},i))}};t.preventDefault(),!1===o.getState().isOpen&&(n.openOnFocus||Boolean(o.getState().query))?An(Tn({event:t,props:n,query:o.getState().query,refresh:r,store:o},i)).then((function(){o.dispatch(t.key,{nextActiveItemId:n.defaultActiveItemId}),a(),setTimeout(c,0)})):(o.dispatch(t.key,{}),a(),c())}else if("Escape"===t.key)t.preventDefault(),o.dispatch(t.key,null),o.pendingRequests.cancelAll();else if("Tab"===t.key)o.dispatch("blur",null),o.pendingRequests.cancelAll();else if("Enter"===t.key){if(null===o.getState().activeItemId||o.getState().collections.every((function(e){return 0===e.items.length})))return void(n.debug||o.pendingRequests.cancelAll());t.preventDefault();var u=Ft(o.getState()),l=u.item,s=u.itemInputValue,f=u.itemUrl,p=u.source;if(t.metaKey||t.ctrlKey)void 0!==f&&(p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewTab({itemUrl:f,item:l,state:o.getState()}));else if(t.shiftKey)void 0!==f&&(p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewWindow({itemUrl:f,item:l,state:o.getState()}));else if(t.altKey);else{if(void 0!==f)return p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),void n.navigator.navigate({itemUrl:f,item:l,state:o.getState()});An(Tn({event:t,nextState:{isOpen:!1},props:n,query:s,refresh:r,store:o},i)).then((function(){p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i))}))}}}(zn({event:e,props:t,refresh:n,store:r},o))},onFocus:c,onBlur:ot,onClick:function(n){e.inputElement!==t.environment.document.activeElement||r.getState().isOpen||c(n)}},s)},getPanelProps:function(e){return zn({onMouseDown:function(e){e.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},e)},getListProps:function(e){var n=e||{},r=n.sourceIndex,o=$n(n,Vn);return zn({role:"listbox","aria-labelledby":"".concat(i(t.id,r),"-label"),id:"".concat(i(t.id,r),"-list")},o)},getItemProps:function(e){var c=e.item,a=e.source,u=e.sourceIndex,l=$n(e,Wn);return zn({id:"".concat(i(t.id,u),"-item-").concat(c.__autocomplete_id),role:"option","aria-selected":r.getState().activeItemId===c.__autocomplete_id,onMouseMove:function(e){if(c.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",c.__autocomplete_id);var t=Ft(r.getState());if(null!==r.getState().activeItemId&&t){var i=t.item,a=t.itemInputValue,u=t.itemUrl,l=t.source;l.onActive(zn({event:e,item:i,itemInputValue:a,itemUrl:u,refresh:n,source:l,state:r.getState()},o))}}},onMouseDown:function(e){e.preventDefault()},onClick:function(e){var i=a.getItemInputValue({item:c,state:r.getState()}),u=a.getItemUrl({item:c,state:r.getState()});(u?Promise.resolve():An(zn({event:e,nextState:{isOpen:!1},props:t,query:i,refresh:n,store:r},o))).then((function(){a.onSelect(zn({event:e,item:c,itemInputValue:i,itemUrl:u,refresh:n,source:a,state:r.getState()},o))}))}},l)}}}function Zn(e){return Zn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Zn(e)}function Yn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Gn(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function xr(e){var t=e.translations,n=void 0===t?{}:t,r=Cr(e,Dr),o=n.noResultsText,i=void 0===o?"No results for":o,c=n.suggestedQueryText,a=void 0===c?"Try searching for":c,u=n.reportMissingResultsText,l=void 0===u?"Believe this query should return results?":u,s=n.reportMissingResultsLinkText,f=void 0===s?"Let us know.":s,p=r.state.context.searchSuggestions;return Be.createElement("div",{className:"DocSearch-NoResults"},Be.createElement("div",{className:"DocSearch-Screen-Icon"},Be.createElement(Pr,null)),Be.createElement("p",{className:"DocSearch-Title"},i,' "',Be.createElement("strong",null,r.state.query),'"'),p&&p.length>0&&Be.createElement("div",{className:"DocSearch-NoResults-Prefill-List"},Be.createElement("p",{className:"DocSearch-Help"},a,":"),Be.createElement("ul",null,p.slice(0,3).reduce((function(e,t){return[].concat(kr(e),[Be.createElement("li",{key:t},Be.createElement("button",{className:"DocSearch-Prefill",key:t,type:"button",onClick:function(){r.setQuery(t.toLowerCase()+" "),r.refresh(),r.inputRef.current.focus()}},t))])}),[]))),r.getMissingResultsUrl&&Be.createElement("p",{className:"DocSearch-Help"},"".concat(l," "),Be.createElement("a",{href:r.getMissingResultsUrl({query:r.state.query}),target:"_blank",rel:"noopener noreferrer"},f)))}var Nr=["hit","attribute","tagName"];function Tr(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Rr(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Mr(e,t){return t.split(".").reduce((function(e,t){return null!=e&&e[t]?e[t]:null}),e)}function Hr(e){var t=e.hit,n=e.attribute,r=e.tagName;return g(void 0===r?"span":r,Rr(Rr({},Lr(e,Nr)),{},{dangerouslySetInnerHTML:{__html:Mr(t,"_snippetResult.".concat(n,".value"))||Mr(t,n)}}))}function Ur(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return Fr(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Fr(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Fr(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n|<\/mark>)/g,Zr=RegExp(Qr.source);function Yr(e){var t,n,r,o,i,c=e;if(!c.__docsearch_parent&&!e._highlightResult)return e.hierarchy.lvl0;var a=((c.__docsearch_parent?null===(t=c.__docsearch_parent)||void 0===t||null===(n=t._highlightResult)||void 0===n||null===(r=n.hierarchy)||void 0===r?void 0:r.lvl0:null===(o=e._highlightResult)||void 0===o||null===(i=o.hierarchy)||void 0===i?void 0:i.lvl0)||{}).value;return a&&Zr.test(a)?a.replace(Qr,""):a}function Gr(){return Gr=Object.assign||function(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function ro(e){var t=e.translations,n=void 0===t?{}:t,r=no(e,eo),o=n.recentSearchesTitle,i=void 0===o?"Recent":o,c=n.noRecentSearchesText,a=void 0===c?"No recent searches":c,u=n.saveRecentSearchButtonTitle,l=void 0===u?"Save this search":u,s=n.removeRecentSearchButtonTitle,f=void 0===s?"Remove this search from history":s,p=n.favoriteSearchesTitle,m=void 0===p?"Favorite":p,v=n.removeFavoriteSearchButtonTitle,d=void 0===v?"Remove this search from favorites":v;return"idle"===r.state.status&&!1===r.hasCollections?r.disableUserPersonalization?null:Be.createElement("div",{className:"DocSearch-StartScreen"},Be.createElement("p",{className:"DocSearch-Help"},a)):!1===r.hasCollections?null:Be.createElement("div",{className:"DocSearch-Dropdown-Container"},Be.createElement(Vr,to({},r,{title:i,collection:r.state.collections[0],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(yr,null))},renderAction:function(e){var t=e.item,n=e.runFavoriteTransition,o=e.runDeleteTransition;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:l,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.add(t),r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(wr,null))),Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:f,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),o((function(){r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(br,null))))}})),Be.createElement(Vr,to({},r,{title:m,collection:r.state.collections[1],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(wr,null))},renderAction:function(e){var t=e.item,n=e.runDeleteTransition;return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:d,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.remove(t),r.refresh()}))}},Be.createElement(br,null)))}})))}var oo=["translations"];function io(){return io=Object.assign||function(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var ao=Be.memo((function(e){var t=e.translations,n=void 0===t?{}:t,r=co(e,oo);if("error"===r.state.status)return Be.createElement(Ir,{translations:null==n?void 0:n.errorScreen});var o=r.state.collections.some((function(e){return e.items.length>0}));return r.state.query?!1===o?Be.createElement(xr,io({},r,{translations:null==n?void 0:n.noResultsScreen})):Be.createElement(Xr,r):Be.createElement(ro,io({},r,{hasCollections:o,translations:null==n?void 0:n.startScreen}))}),(function(e,t){return"loading"===t.state.status||"stalled"===t.state.status})),uo=["translations"];function lo(){return lo=Object.assign||function(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function fo(e){var t=e.translations,n=void 0===t?{}:t,r=so(e,uo),o=n.resetButtonTitle,i=void 0===o?"Clear the query":o,c=n.resetButtonAriaLabel,a=void 0===c?"Clear the query":c,u=n.cancelButtonText,l=void 0===u?"Cancel":u,s=n.cancelButtonAriaLabel,f=void 0===s?"Cancel":s,p=r.getFormProps({inputElement:r.inputRef.current}).onReset;return Be.useEffect((function(){r.autoFocus&&r.inputRef.current&&r.inputRef.current.focus()}),[r.autoFocus,r.inputRef]),Be.useEffect((function(){r.isFromSelection&&r.inputRef.current&&r.inputRef.current.select()}),[r.isFromSelection,r.inputRef]),Be.createElement(Be.Fragment,null,Be.createElement("form",{className:"DocSearch-Form",onSubmit:function(e){e.preventDefault()},onReset:p},Be.createElement("label",lo({className:"DocSearch-MagnifierLabel"},r.getLabelProps()),Be.createElement(We,null)),Be.createElement("div",{className:"DocSearch-LoadingIndicator"},Be.createElement(hr,null)),Be.createElement("input",lo({className:"DocSearch-Input",ref:r.inputRef},r.getInputProps({inputElement:r.inputRef.current,autoFocus:r.autoFocus,maxLength:64}))),Be.createElement("button",{type:"reset",title:i,className:"DocSearch-Reset","aria-label":a,hidden:!r.state.query},Be.createElement(br,null))),Be.createElement("button",{className:"DocSearch-Cancel",type:"reset","aria-label":f,onClick:r.onClose},l))}var po=["_highlightResult","_snippetResult"];function mo(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function vo(e){return!1===function(){var e="__TEST_KEY__";try{return localStorage.setItem(e,""),localStorage.removeItem(e),!0}catch(e){return!1}}()?{setItem:function(){},getItem:function(){return[]}}:{setItem:function(t){return window.localStorage.setItem(e,JSON.stringify(t))},getItem:function(){var t=window.localStorage.getItem(e);return t?JSON.parse(t):[]}}}function ho(e){var t=e.key,n=e.limit,r=void 0===n?5:n,o=vo(t),i=o.getItem().slice(0,r);return{add:function(e){var t=e,n=(t._highlightResult,t._snippetResult,mo(t,po)),c=i.findIndex((function(e){return e.objectID===n.objectID}));c>-1&&i.splice(c,1),i.unshift(n),i=i.slice(0,r),o.setItem(i)},remove:function(e){i=i.filter((function(t){return t.objectID!==e.objectID})),o.setItem(i)},getAll:function(){return i}}}var yo=["facetName","facetQuery"];function bo(e){var t,n="algoliasearch-client-js-".concat(e.key),r=function(){return void 0===t&&(t=e.localStorage||window.localStorage),t},o=function(){return JSON.parse(r().getItem(n)||"{}")};return{get:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then((function(){var n=JSON.stringify(e),r=o()[n];return Promise.all([r||t(),void 0!==r])})).then((function(e){var t=c(e,2),r=t[0],o=t[1];return Promise.all([r,o||n.miss(r)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve().then((function(){var i=o();return i[JSON.stringify(e)]=t,r().setItem(n,JSON.stringify(i)),t}))},delete:function(e){return Promise.resolve().then((function(){var t=o();delete t[JSON.stringify(e)],r().setItem(n,JSON.stringify(t))}))},clear:function(){return Promise.resolve().then((function(){r().removeItem(n)}))}}}function _o(e){var t=a(e.caches),n=t.shift();return void 0===n?{get:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return t().then((function(e){return Promise.all([e,n.miss(e)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve(t)},delete:function(e){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(e,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return n.get(e,r,o).catch((function(){return _o({caches:t}).get(e,r,o)}))},set:function(e,r){return n.set(e,r).catch((function(){return _o({caches:t}).set(e,r)}))},delete:function(e){return n.delete(e).catch((function(){return _o({caches:t}).delete(e)}))},clear:function(){return n.clear().catch((function(){return _o({caches:t}).clear()}))}}}function go(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{serializable:!0},t={};return{get:function(n,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},i=JSON.stringify(n);if(i in t)return Promise.resolve(e.serializable?JSON.parse(t[i]):t[i]);var c=r(),a=o&&o.miss||function(){return Promise.resolve()};return c.then((function(e){return a(e)})).then((function(){return c}))},set:function(n,r){return t[JSON.stringify(n)]=e.serializable?JSON.stringify(r):r,Promise.resolve(r)},delete:function(e){return delete t[JSON.stringify(e)],Promise.resolve()},clear:function(){return t={},Promise.resolve()}}}function Oo(e){for(var t=e.length-1;t>0;t--){var n=Math.floor(Math.random()*(t+1)),r=e[t];e[t]=e[n],e[n]=r}return e}function So(e,t){return t?(Object.keys(t).forEach((function(n){e[n]=t[n](e)})),e):e}function jo(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r0?r:void 0,timeout:n.timeout||t,headers:n.headers||{},queryParameters:n.queryParameters||{},cacheable:n.cacheable}}var Io={Read:1,Write:2,Any:3},Do=1,ko=2,Ao=3,Co=12e4;function xo(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Do;return t(t({},e),{},{status:n,lastUpdate:Date.now()})}function No(e){return"string"==typeof e?{protocol:"https",url:e,accept:Io.Any}:{protocol:e.protocol||"https",url:e.url,accept:e.accept||Io.Any}}var To="GET",Ro="POST";function qo(e,t){return Promise.all(t.map((function(t){return e.get(t,(function(){return Promise.resolve(xo(t))}))}))).then((function(e){var n=e.filter((function(e){return function(e){return e.status===Do||Date.now()-e.lastUpdate>Co}(e)})),r=e.filter((function(e){return function(e){return e.status===Ao&&Date.now()-e.lastUpdate<=Co}(e)})),o=[].concat(a(n),a(r));return{getTimeout:function(e,t){return(0===r.length&&0===e?1:r.length+3+e)*t},statelessHosts:o.length>0?o.map((function(e){return No(e)})):t}}))}function Lo(e,n,r,o){var i=[],c=function(e,n){if(e.method===To||void 0===e.data&&void 0===n.data)return;var r=Array.isArray(e.data)?e.data:t(t({},e.data),n.data);return JSON.stringify(r)}(r,o),u=function(e,n){var r=t(t({},e.headers),n.headers),o={};return Object.keys(r).forEach((function(e){var t=r[e];o[e.toLowerCase()]=t})),o}(e,o),l=r.method,s=r.method!==To?{}:t(t({},r.data),o.data),f=t(t(t({"x-algolia-agent":e.userAgent.value},e.queryParameters),s),o.queryParameters),p=0,m=function t(n,a){var s=n.pop();if(void 0===s)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:Fo(i)};var m={data:c,headers:u,method:l,url:Ho(s,r.path,f),connectTimeout:a(p,e.timeouts.connect),responseTimeout:a(p,o.timeout)},v=function(e){var t={request:m,response:e,host:s,triesLeft:n.length};return i.push(t),t},d={onSucess:function(e){return function(e){try{return JSON.parse(e.content)}catch(t){throw function(e,t){return{name:"DeserializationError",message:e,response:t}}(t.message,e)}}(e)},onRetry:function(r){var o=v(r);return r.isTimedOut&&p++,Promise.all([e.logger.info("Retryable failure",Bo(o)),e.hostsCache.set(s,xo(s,r.isTimedOut?Ao:ko))]).then((function(){return t(n,a)}))},onFail:function(e){throw v(e),function(e,t){var n=e.content,r=e.status,o=n;try{o=JSON.parse(n).message}catch(e){}return function(e,t,n){return{name:"ApiError",message:e,status:t,transporterStackTrace:n}}(o,r,t)}(e,Fo(i))}};return e.requester.send(m).then((function(e){return function(e,t){return function(e){var t=e.status;return e.isTimedOut||function(e){var t=e.isTimedOut,n=e.status;return!t&&0==~~n}(e)||2!=~~(t/100)&&4!=~~(t/100)}(e)?t.onRetry(e):2==~~(e.status/100)?t.onSucess(e):t.onFail(e)}(e,d)}))};return qo(e.hostsCache,n).then((function(e){return m(a(e.statelessHosts).reverse(),e.getTimeout)}))}function Mo(e){var t={value:"Algolia for JavaScript (".concat(e,")"),add:function(e){var n="; ".concat(e.segment).concat(void 0!==e.version?" (".concat(e.version,")"):"");return-1===t.value.indexOf(n)&&(t.value="".concat(t.value).concat(n)),t}};return t}function Ho(e,t,n){var r=Uo(n),o="".concat(e.protocol,"://").concat(e.url,"/").concat("/"===t.charAt(0)?t.substr(1):t);return r.length&&(o+="?".concat(r)),o}function Uo(e){return Object.keys(e).map((function(t){return jo("%s=%s",t,(n=e[t],"[object Object]"===Object.prototype.toString.call(n)||"[object Array]"===Object.prototype.toString.call(n)?JSON.stringify(e[t]):e[t]));var n})).join("&")}function Fo(e){return e.map((function(e){return Bo(e)}))}function Bo(e){var n=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return t(t({},e),{},{request:t(t({},e.request),{},{headers:t(t({},e.request.headers),n)})})}var Vo=function(e){var n=e.appId,r=function(e,t,n){var r={"x-algolia-api-key":n,"x-algolia-application-id":t};return{headers:function(){return e===Eo.WithinHeaders?r:{}},queryParameters:function(){return e===Eo.WithinQueryParameters?r:{}}}}(void 0!==e.authMode?e.authMode:Eo.WithinHeaders,n,e.apiKey),o=function(e){var t=e.hostsCache,n=e.logger,r=e.requester,o=e.requestsCache,i=e.responsesCache,a=e.timeouts,u=e.userAgent,l=e.hosts,s=e.queryParameters,f={hostsCache:t,logger:n,requester:r,requestsCache:o,responsesCache:i,timeouts:a,userAgent:u,headers:e.headers,queryParameters:s,hosts:l.map((function(e){return No(e)})),read:function(e,t){var n=Po(t,f.timeouts.read),r=function(){return Lo(f,f.hosts.filter((function(e){return 0!=(e.accept&Io.Read)})),e,n)};if(!0!==(void 0!==n.cacheable?n.cacheable:e.cacheable))return r();var o={request:e,mappedRequestOptions:n,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(o,(function(){return f.requestsCache.get(o,(function(){return f.requestsCache.set(o,r()).then((function(e){return Promise.all([f.requestsCache.delete(o),e])}),(function(e){return Promise.all([f.requestsCache.delete(o),Promise.reject(e)])})).then((function(e){var t=c(e,2);return t[0],t[1]}))}))}),{miss:function(e){return f.responsesCache.set(o,e)}})},write:function(e,t){return Lo(f,f.hosts.filter((function(e){return 0!=(e.accept&Io.Write)})),e,Po(t,f.timeouts.write))}};return f}(t(t({hosts:[{url:"".concat(n,"-dsn.algolia.net"),accept:Io.Read},{url:"".concat(n,".algolia.net"),accept:Io.Write}].concat(Oo([{url:"".concat(n,"-1.algolianet.com")},{url:"".concat(n,"-2.algolianet.com")},{url:"".concat(n,"-3.algolianet.com")}]))},e),{},{headers:t(t(t({},r.headers()),{"content-type":"application/x-www-form-urlencoded"}),e.headers),queryParameters:t(t({},r.queryParameters()),e.queryParameters)})),i={transporter:o,appId:n,addAlgoliaAgent:function(e,t){o.userAgent.add({segment:e,version:t})},clearCache:function(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then((function(){}))}};return So(i,e.methods)},Wo=function(e){return function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r={transporter:e.transporter,appId:e.appId,indexName:t};return So(r,n.methods)}},Ko=function(e){return function(n,r){var o=n.map((function(e){return t(t({},e),{},{params:Uo(e.params||{})})}));return e.transporter.read({method:Ro,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)}},zo=function(e){return function(n,r){return Promise.all(n.map((function(n){var o=n.params,c=o.facetName,a=o.facetQuery,u=i(o,yo);return Wo(e)(n.indexName,{methods:{searchForFacetValues:Qo}}).searchForFacetValues(c,a,t(t({},r),u))})))}},Jo=function(e){return function(t,n,r){return e.transporter.read({method:Ro,path:jo("1/answers/%s/prediction",e.indexName),data:{query:t,queryLanguages:n},cacheable:!0},r)}},$o=function(e){return function(t,n){return e.transporter.read({method:Ro,path:jo("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},n)}},Qo=function(e){return function(t,n,r){return e.transporter.read({method:Ro,path:jo("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:n},cacheable:!0},r)}},Zo=1,Yo=2,Go=3;function Xo(e,n,r){var o,i={appId:e,apiKey:n,timeouts:{connect:1,read:2,write:30},requester:{send:function(e){return new Promise((function(t){var n=new XMLHttpRequest;n.open(e.method,e.url,!0),Object.keys(e.headers).forEach((function(t){return n.setRequestHeader(t,e.headers[t])}));var r,o=function(e,r){return setTimeout((function(){n.abort(),t({status:0,content:r,isTimedOut:!0})}),1e3*e)},i=o(e.connectTimeout,"Connection timeout");n.onreadystatechange=function(){n.readyState>n.OPENED&&void 0===r&&(clearTimeout(i),r=o(e.responseTimeout,"Socket timeout"))},n.onerror=function(){0===n.status&&(clearTimeout(i),clearTimeout(r),t({content:n.responseText||"Network request failed",status:n.status,isTimedOut:!1}))},n.onload=function(){clearTimeout(i),clearTimeout(r),t({content:n.responseText,status:n.status,isTimedOut:!1})},n.send(e.data)}))}},logger:(o=Go,{debug:function(e,t){return Zo>=o&&console.debug(e,t),Promise.resolve()},info:function(e,t){return Yo>=o&&console.info(e,t),Promise.resolve()},error:function(e,t){return console.error(e,t),Promise.resolve()}}),responsesCache:go(),requestsCache:go({serializable:!1}),hostsCache:_o({caches:[bo({key:"".concat(wo,"-").concat(e)}),go()]}),userAgent:Mo(wo).add({segment:"Browser",version:"lite"}),authMode:Eo.WithinQueryParameters};return Vo(t(t(t({},i),r),{},{methods:{search:Ko,searchForFacetValues:zo,multipleQueries:Ko,multipleSearchForFacetValues:zo,initIndex:function(e){return function(t){return Wo(e)(t,{methods:{search:$o,searchForFacetValues:Qo,findAnswers:Jo}})}}}}))}Xo.version=wo;var ei="3.5.1";var ti=["footer","searchBox"];function ni(){return ni=Object.assign||function(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function li(e){var t=e.appId,n=e.apiKey,r=e.indexName,o=e.placeholder,i=void 0===o?"Search docs":o,c=e.searchParameters,a=e.maxResultsPerGroup,u=e.onClose,l=void 0===u?$r:u,s=e.transformItems,f=void 0===s?zr:s,p=e.hitComponent,m=void 0===p?dr:p,v=e.resultsFooterComponent,d=void 0===v?function(){return null}:v,h=e.navigator,y=e.initialScrollY,b=void 0===y?0:y,_=e.transformSearchClient,g=void 0===_?zr:_,O=e.disableUserPersonalization,S=void 0!==O&&O,j=e.initialQuery,w=void 0===j?"":j,E=e.translations,P=void 0===E?{}:E,I=e.getMissingResultsUrl,D=e.insights,k=void 0!==D&&D,A=P.footer,C=P.searchBox,x=ui(P,ti),N=ci(Be.useState({query:"",collections:[],completion:null,context:{},isOpen:!1,activeItemId:null,status:"idle"}),2),T=N[0],R=N[1],q=Be.useRef(null),L=Be.useRef(null),M=Be.useRef(null),H=Be.useRef(null),U=Be.useRef(null),F=Be.useRef(10),B=Be.useRef("undefined"!=typeof window?window.getSelection().toString().slice(0,64):"").current,V=Be.useRef(w||B).current,W=function(e,t,n){return Be.useMemo((function(){var r=Xo(e,t);return r.addAlgoliaAgent("docsearch",ei),!1===/docsearch.js \(.*\)/.test(r.transporter.userAgent.value)&&r.addAlgoliaAgent("docsearch-react",ei),n(r)}),[e,t,n])}(t,n,g),K=Be.useRef(ho({key:"__DOCSEARCH_FAVORITE_SEARCHES__".concat(r),limit:10})).current,z=Be.useRef(ho({key:"__DOCSEARCH_RECENT_SEARCHES__".concat(r),limit:0===K.getAll().length?7:4})).current,J=Be.useCallback((function(e){if(!S){var t="content"===e.type?e.__docsearch_parent:e;t&&-1===K.getAll().findIndex((function(e){return e.objectID===t.objectID}))&&z.add(t)}}),[K,z,S]),$=Be.useCallback((function(e){if(T.context.algoliaInsightsPlugin&&e.__autocomplete_id){var t=e,n={eventName:"Item Selected",index:t.__autocomplete_indexName,items:[t],positions:[e.__autocomplete_id],queryID:t.__autocomplete_queryID};T.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(n)}}),[T.context.algoliaInsightsPlugin]),Q=Be.useMemo((function(){return fr({id:"docsearch",defaultActiveItemId:0,placeholder:i,openOnFocus:!0,initialState:{query:V,context:{searchSuggestions:[]}},insights:k,navigator:h,onStateChange:function(e){R(e.state)},getSources:function(e){var o=e.query,i=e.state,u=e.setContext,s=e.setStatus;if(!o)return S?[]:[{sourceId:"recentSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Jr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return z.getAll()}},{sourceId:"favoriteSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Jr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return K.getAll()}}];var p=Boolean(k);return W.search([{query:o,indexName:r,params:oi({attributesToRetrieve:["hierarchy.lvl0","hierarchy.lvl1","hierarchy.lvl2","hierarchy.lvl3","hierarchy.lvl4","hierarchy.lvl5","hierarchy.lvl6","content","type","url"],attributesToSnippet:["hierarchy.lvl1:".concat(F.current),"hierarchy.lvl2:".concat(F.current),"hierarchy.lvl3:".concat(F.current),"hierarchy.lvl4:".concat(F.current),"hierarchy.lvl5:".concat(F.current),"hierarchy.lvl6:".concat(F.current),"content:".concat(F.current)],snippetEllipsisText:"…",highlightPreTag:"",highlightPostTag:"",hitsPerPage:20,clickAnalytics:p},c)}]).catch((function(e){throw"RetryError"===e.name&&s("error"),e})).then((function(e){var o=e.results,c=o[0],s=c.hits,m=c.nbHits,v=Kr(s,(function(e){return Yr(e)}),a);i.context.searchSuggestions.length0&&(G(),U.current&&U.current.focus())}),[V,G]),Be.useEffect((function(){function e(){if(L.current){var e=.01*window.innerHeight;L.current.style.setProperty("--docsearch-vh","".concat(e,"px"))}}return e(),window.addEventListener("resize",e),function(){window.removeEventListener("resize",e)}}),[]),Be.createElement("div",ni({ref:q},Y({"aria-expanded":!0}),{className:["DocSearch","DocSearch-Container","stalled"===T.status&&"DocSearch-Container--Stalled","error"===T.status&&"DocSearch-Container--Errored"].filter(Boolean).join(" "),role:"button",tabIndex:0,onMouseDown:function(e){e.target===e.currentTarget&&l()}}),Be.createElement("div",{className:"DocSearch-Modal",ref:L},Be.createElement("header",{className:"DocSearch-SearchBar",ref:M},Be.createElement(fo,ni({},Q,{state:T,autoFocus:0===V.length,inputRef:U,isFromSelection:Boolean(V)&&V===B,translations:C,onClose:l}))),Be.createElement("div",{className:"DocSearch-Dropdown",ref:H},Be.createElement(ao,ni({},Q,{indexName:r,state:T,hitComponent:m,resultsFooterComponent:d,disableUserPersonalization:S,recentSearches:z,favoriteSearches:K,inputRef:U,translations:x,getMissingResultsUrl:I,onItemClick:function(e,t){$(e),J(e),Jr(t)||l()}}))),Be.createElement("footer",{className:"DocSearch-Footer"},Be.createElement(vr,{translations:A}))))}function si(){return si=Object.assign||function(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n1&&void 0!==arguments[1]?arguments[1]:window;return"string"==typeof e?t.document.querySelector(e):e}(e.container,e.environment))}})); +/*! @docsearch/js 3.6.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).docsearch=t()}(this,(function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function c(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(e){return function(e){if(Array.isArray(e))return l(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||u(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(e,t){if(e){if("string"==typeof e)return l(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(e,t):void 0}}function l(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n3)for(n=[n],i=3;i0?S(m.type,m.props,m.key,null,m.__v):m)){if(m.__=n,m.__b=n.__b+1,null===(p=b[s])||p&&m.key==p.key&&m.type===p.type)b[s]=void 0;else for(f=0;f3)for(n=[n],i=3;i=n.__.length&&n.__.push({}),n.__[e]}function ne(e){return $=1,re(pe,e)}function re(e,t,n){var r=te(W++,2);return r.t=e,r.__c||(r.__=[n?n(t):pe(void 0,t),function(e){var t=r.t(r.__[0],e);r.__[0]!==t&&(r.__=[t,r.__[1]],r.__c.setState({}))}],r.__c=z),r.__}function oe(e,t){var n=te(W++,3);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__H.__h.push(n))}function ie(e,t){var n=te(W++,4);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__h.push(n))}function ce(e,t){var n=te(W++,7);return fe(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function ae(){Z.forEach((function(e){if(e.__P)try{e.__H.__h.forEach(le),e.__H.__h.forEach(se),e.__H.__h=[]}catch(t){e.__H.__h=[],s.__e(t,e.__v)}})),Z=[]}s.__b=function(e){z=null,Q&&Q(e)},s.__r=function(e){Y&&Y(e),W=0;var t=(z=e.__c).__H;t&&(t.__h.forEach(le),t.__h.forEach(se),t.__h=[])},s.diffed=function(e){G&&G(e);var t=e.__c;t&&t.__H&&t.__H.__h.length&&(1!==Z.push(t)&&J===s.requestAnimationFrame||((J=s.requestAnimationFrame)||function(e){var t,n=function(){clearTimeout(r),ue&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,100);ue&&(t=requestAnimationFrame(n))})(ae)),z=void 0},s.__c=function(e,t){t.some((function(e){try{e.__h.forEach(le),e.__h=e.__h.filter((function(e){return!e.__||se(e)}))}catch(n){t.some((function(e){e.__h&&(e.__h=[])})),t=[],s.__e(n,e.__v)}})),X&&X(e,t)},s.unmount=function(e){ee&&ee(e);var t=e.__c;if(t&&t.__H)try{t.__H.__.forEach(le)}catch(e){s.__e(e,t.__v)}};var ue="function"==typeof requestAnimationFrame;function le(e){var t=z;"function"==typeof e.__c&&e.__c(),z=t}function se(e){var t=z;e.__c=e.__(),z=t}function fe(e,t){return!e||e.length!==t.length||t.some((function(t,n){return t!==e[n]}))}function pe(e,t){return"function"==typeof t?t(e):t}function me(e,t){for(var n in t)e[n]=t[n];return e}function de(e,t){for(var n in e)if("__source"!==n&&!(n in t))return!0;for(var r in t)if("__source"!==r&&e[r]!==t[r])return!0;return!1}function ve(e){this.props=e}(ve.prototype=new w).isPureReactComponent=!0,ve.prototype.shouldComponentUpdate=function(e,t){return de(this.props,e)||de(this.state,t)};var he=s.__b;s.__b=function(e){e.type&&e.type.__f&&e.ref&&(e.props.ref=e.ref,e.ref=null),he&&he(e)};var ye="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.forward_ref")||3911;var _e=function(e,t){return null==e?null:C(C(e).map(t))},be={map:_e,forEach:_e,count:function(e){return e?C(e).length:0},only:function(e){var t=C(e);if(1!==t.length)throw"Children.only";return t[0]},toArray:C},ge=s.__e;function Se(){this.__u=0,this.t=null,this.__b=null}function Oe(e){var t=e.__.__c;return t&&t.__e&&t.__e(e)}function we(){this.u=null,this.o=null}s.__e=function(e,t,n){if(e.then)for(var r,o=t;o=o.__;)if((r=o.__c)&&r.__c)return null==t.__e&&(t.__e=n.__e,t.__k=n.__k),r.__c(e,t);ge(e,t,n)},(Se.prototype=new w).__c=function(e,t){var n=t.__c,r=this;null==r.t&&(r.t=[]),r.t.push(n);var o=Oe(r.__v),i=!1,c=function(){i||(i=!0,n.componentWillUnmount=n.__c,o?o(a):a())};n.__c=n.componentWillUnmount,n.componentWillUnmount=function(){c(),n.__c&&n.__c()};var a=function(){if(!--r.__u){if(r.state.__e){var e=r.state.__e;r.__v.__k[0]=function e(t,n,r){return t&&(t.__v=null,t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)})),t.__c&&t.__c.__P===n&&(t.__e&&r.insertBefore(t.__e,t.__d),t.__c.__e=!0,t.__c.__P=r)),t}(e,e.__c.__P,e.__c.__O)}var t;for(r.setState({__e:r.__b=null});t=r.t.pop();)t.forceUpdate()}},u=!0===t.__h;r.__u++||u||r.setState({__e:r.__b=r.__v.__k[0]}),e.then(c,c)},Se.prototype.componentWillUnmount=function(){this.t=[]},Se.prototype.render=function(e,t){if(this.__b){if(this.__v.__k){var n=document.createElement("div"),r=this.__v.__k[0].__c;this.__v.__k[0]=function e(t,n,r){return t&&(t.__c&&t.__c.__H&&(t.__c.__H.__.forEach((function(e){"function"==typeof e.__c&&e.__c()})),t.__c.__H=null),null!=(t=me({},t)).__c&&(t.__c.__P===r&&(t.__c.__P=n),t.__c=null),t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)}))),t}(this.__b,n,r.__O=r.__P)}this.__b=null}var o=t.__e&&g(O,null,e.fallback);return o&&(o.__h=null),[g(O,null,t.__e?null:e.children),o]};var Ee=function(e,t,n){if(++n[1]===n[0]&&e.o.delete(t),e.props.revealOrder&&("t"!==e.props.revealOrder[0]||!e.o.size))for(n=e.u;n;){for(;n.length>3;)n.pop()();if(n[1]>>1,1),t.i.removeChild(e)}}),B(g(je,{context:t.context},e.__v),t.l)):t.l&&t.componentWillUnmount()}function Ie(e,t){return g(Pe,{__v:e,i:t})}(we.prototype=new w).__e=function(e){var t=this,n=Oe(t.__v),r=t.o.get(e);return r[0]++,function(o){var i=function(){t.props.revealOrder?(r.push(o),Ee(t,e,r)):o()};n?n(i):i()}},we.prototype.render=function(e){this.u=null,this.o=new Map;var t=C(e.children);e.revealOrder&&"b"===e.revealOrder[0]&&t.reverse();for(var n=t.length;n--;)this.o.set(t[n],this.u=[1,0,this.u]);return e.children},we.prototype.componentDidUpdate=we.prototype.componentDidMount=function(){var e=this;this.o.forEach((function(t,n){Ee(e,n,t)}))};var De="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,ke=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,Ce=function(e){return("undefined"!=typeof Symbol&&"symbol"==n(Symbol())?/fil|che|rad/i:/fil|che|ra/i).test(e)};function Ae(e,t,n){return null==t.__k&&(t.textContent=""),B(e,t),"function"==typeof n&&n(),e?e.__c:null}w.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach((function(e){Object.defineProperty(w.prototype,e,{configurable:!0,get:function(){return this["UNSAFE_"+e]},set:function(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,value:t})}})}));var xe=s.event;function Ne(){}function Te(){return this.cancelBubble}function Re(){return this.defaultPrevented}s.event=function(e){return xe&&(e=xe(e)),e.persist=Ne,e.isPropagationStopped=Te,e.isDefaultPrevented=Re,e.nativeEvent=e};var qe,Le={configurable:!0,get:function(){return this.class}},Me=s.vnode;s.vnode=function(e){var t=e.type,n=e.props,r=n;if("string"==typeof t){for(var o in r={},n){var i=n[o];"value"===o&&"defaultValue"in n&&null==i||("defaultValue"===o&&"value"in n&&null==n.value?o="value":"download"===o&&!0===i?i="":/ondoubleclick/i.test(o)?o="ondblclick":/^onchange(textarea|input)/i.test(o+t)&&!Ce(n.type)?o="oninput":/^on(Ani|Tra|Tou|BeforeInp)/.test(o)?o=o.toLowerCase():ke.test(o)?o=o.replace(/[A-Z0-9]/,"-$&").toLowerCase():null===i&&(i=void 0),r[o]=i)}"select"==t&&r.multiple&&Array.isArray(r.value)&&(r.value=C(n.children).forEach((function(e){e.props.selected=-1!=r.value.indexOf(e.props.value)}))),"select"==t&&null!=r.defaultValue&&(r.value=C(n.children).forEach((function(e){e.props.selected=r.multiple?-1!=r.defaultValue.indexOf(e.props.value):r.defaultValue==e.props.value}))),e.props=r}t&&n.class!=n.className&&(Le.enumerable="className"in n,null!=n.className&&(r.class=n.className),Object.defineProperty(r,"className",Le)),e.$$typeof=De,Me&&Me(e)};var He=s.__r;s.__r=function(e){He&&He(e),qe=e.__c};var Ue={ReactCurrentDispatcher:{current:{readContext:function(e){return qe.__n[e.__c].props.value}}}};"object"==("undefined"==typeof performance?"undefined":n(performance))&&"function"==typeof performance.now&&performance.now.bind(performance);function Fe(e){return!!e&&e.$$typeof===De}var Be={useState:ne,useReducer:re,useEffect:oe,useLayoutEffect:ie,useRef:function(e){return $=5,ce((function(){return{current:e}}),[])},useImperativeHandle:function(e,t,n){$=6,ie((function(){"function"==typeof e?e(t()):e&&(e.current=t())}),null==n?n:n.concat(e))},useMemo:ce,useCallback:function(e,t){return $=8,ce((function(){return e}),t)},useContext:function(e){var t=z.context[e.__c],n=te(W++,9);return n.__c=e,t?(null==n.__&&(n.__=!0,t.sub(z)),t.props.value):e.__},useDebugValue:function(e,t){s.useDebugValue&&s.useDebugValue(t?t(e):e)},version:"16.8.0",Children:be,render:Ae,hydrate:function(e,t,n){return V(e,t),"function"==typeof n&&n(),e?e.__c:null},unmountComponentAtNode:function(e){return!!e.__k&&(B(null,e),!0)},createPortal:Ie,createElement:g,createContext:function(e,t){var n={__c:t="__cC"+d++,__:e,Consumer:function(e,t){return e.children(t)},Provider:function(e){var n,r;return this.getChildContext||(n=[],(r={})[t]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&n.some(P)},this.sub=function(e){n.push(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.splice(n.indexOf(e),1),t&&t.call(e)}}),e.children}};return n.Provider.__=n.Consumer.contextType=n},createFactory:function(e){return g.bind(null,e)},cloneElement:function(e){return Fe(e)?K.apply(null,arguments):e},createRef:function(){return{current:null}},Fragment:O,isValidElement:Fe,findDOMNode:function(e){return e&&(e.base||1===e.nodeType&&e)||null},Component:w,PureComponent:ve,memo:function(e,t){function n(e){var n=this.props.ref,r=n==e.ref;return!r&&n&&(n.call?n(null):n.current=null),t?!t(this.props,e)||!r:de(this.props,e)}function r(t){return this.shouldComponentUpdate=n,g(e,t)}return r.displayName="Memo("+(e.displayName||e.name)+")",r.prototype.isReactComponent=!0,r.__f=!0,r},forwardRef:function(e){function t(t,r){var o=me({},t);return delete o.ref,e(o,(r=t.ref||r)&&("object"!=n(r)||"current"in r)?r:null)}return t.$$typeof=ye,t.render=t,t.prototype.isReactComponent=t.__f=!0,t.displayName="ForwardRef("+(e.displayName||e.name)+")",t},unstable_batchedUpdates:function(e,t){return e(t)},StrictMode:O,Suspense:Se,SuspenseList:we,lazy:function(e){var t,n,r;function o(o){if(t||(t=e()).then((function(e){n=e.default||e}),(function(e){r=e})),r)throw r;if(!n)throw t;return g(n,o)}return o.displayName="Lazy",o.__f=!0,o},__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:Ue},Ve=["facetName","facetQuery"];function Ke(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function We(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Ze(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}}(e,t)||Qe(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Qe(e,t){if(e){if("string"==typeof e)return Ye(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Ye(e,t):void 0}}function Ye(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function bt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function gt(e){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:20,n=[],r=0;r=3||2===n&&r>=4||1===n&&r>=10);function i(t,n,r){if(o&&void 0!==r){var i=r[0].__autocomplete_algoliaCredentials,c={"X-Algolia-Application-Id":i.appId,"X-Algolia-API-Key":i.apiKey};e.apply(void 0,[t].concat(ht(n),[{headers:c}]))}else e.apply(void 0,[t].concat(ht(n)))}return{init:function(t,n){e("init",{appId:t,apiKey:n})},setUserToken:function(t){e("setUserToken",t)},clickedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDsAfterSearch",wt(t),t[0].items)},clickedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("clickedObjectIDs",wt(t),t[0].items)},clickedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["clickedFilters"].concat(n))},convertedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDsAfterSearch",wt(t),t[0].items)},convertedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&i("convertedObjectIDs",wt(t),t[0].items)},convertedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["convertedFilters"].concat(n))},viewedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n0&&t.reduce((function(e,t){var n=t.items,r=_t(t,dt);return[].concat(ht(e),ht(Ot(gt(gt({},r),{},{objectIDs:(null==n?void 0:n.map((function(e){return e.objectID})))||r.objectIDs})).map((function(e){return{items:n,payload:e}}))))}),[]).forEach((function(e){var t=e.items;return i("viewedObjectIDs",[e.payload],t)}))},viewedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r0&&e.apply(void 0,["viewedFilters"].concat(n))}}}function jt(e){var t=e.items.reduce((function(e,t){var n;return e[t.__autocomplete_indexName]=(null!==(n=e[t.__autocomplete_indexName])&&void 0!==n?n:[]).concat(t),e}),{});return Object.keys(t).map((function(e){return{index:e,items:t[e],algoliaSource:["autocomplete"]}}))}function Pt(e){return e.objectID&&e.__autocomplete_indexName&&e.__autocomplete_queryID}function It(e){return It="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},It(e)}function Dt(e){return function(e){if(Array.isArray(e))return kt(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return kt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?kt(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function kt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&Tt({onItemsChange:r,items:n,insights:a,state:t}))}}),0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(e){var t=e.setContext,n=e.onSelect,r=e.onActive;c("addAlgoliaAgent","insights-plugin"),t({algoliaInsightsPlugin:{__algoliaSearchParameters:{clickAnalytics:!0},insights:a}}),n((function(e){var t=e.item,n=e.state,r=e.event;Pt(t)&&o({state:n,event:r,insights:a,item:t,insightsEvents:[At({eventName:"Item Selected"},ft({item:t,items:u.current}))]})})),r((function(e){var t=e.item,n=e.state,r=e.event;Pt(t)&&i({state:n,event:r,insights:a,item:t,insightsEvents:[At({eventName:"Item Active"},ft({item:t,items:u.current}))]})}))},onStateChange:function(e){var t=e.state;l({state:t})},__autocomplete_pluginOptions:e}}function qt(e,t){var n=t;return{then:function(t,r){return qt(e.then(Mt(t,n,e),Mt(r,n,e)),n)},catch:function(t){return qt(e.catch(Mt(t,n,e)),n)},finally:function(t){return t&&n.onCancelList.push(t),qt(e.finally(Mt(t&&function(){return n.onCancelList=[],t()},n,e)),n)},cancel:function(){n.isCanceled=!0;var e=n.onCancelList;n.onCancelList=[],e.forEach((function(e){e()}))},isCanceled:function(){return!0===n.isCanceled}}}function Lt(e){return qt(e,{isCanceled:!1,onCancelList:[]})}function Mt(e,t,n){return e?function(n){return t.isCanceled?n:e(n)}:n}function Ht(e,t,n,r){if(!n)return null;if(e<0&&(null===t||null!==r&&0===t))return n+e;var o=(null===t?-1:t)+e;return o<=-1||o>=n?null===r?null:0:o}function Ut(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Ft(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n0},reshape:function(e){return e.sources}},e),{},{id:null!==(n=e.id)&&void 0!==n?n:"autocomplete-".concat(it++),plugins:o,initialState:nn({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},e.initialState),onStateChange:function(t){var n;null===(n=e.onStateChange)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onStateChange)||void 0===n?void 0:n.call(e,t)}))},onSubmit:function(t){var n;null===(n=e.onSubmit)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onSubmit)||void 0===n?void 0:n.call(e,t)}))},onReset:function(t){var n;null===(n=e.onReset)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onReset)||void 0===n?void 0:n.call(e,t)}))},getSources:function(n){return Promise.all([].concat(function(e){return function(e){if(Array.isArray(e))return en(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return en(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?en(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(o.map((function(e){return e.getSources}))),[e.getSources]).filter(Boolean).map((function(e){return function(e,t){var n=[];return Promise.resolve(e(t)).then((function(e){return Promise.all(e.filter((function(e){return Boolean(e)})).map((function(e){if(e.sourceId,n.includes(e.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(e.sourceId)," is not unique."));n.push(e.sourceId);var t={getItemInputValue:function(e){return e.state.query},getItemUrl:function(){},onSelect:function(e){(0,e.setIsOpen)(!1)},onActive:lt,onResolve:lt};Object.keys(t).forEach((function(e){t[e].__default=!0}));var r=Ft(Ft({},t),e);return Promise.resolve(r)})))}))}(e,n)}))).then((function(e){return ot(e)})).then((function(e){return e.map((function(e){return nn(nn({},e),{},{onSelect:function(n){e.onSelect(n),t.forEach((function(e){var t;return null===(t=e.onSelect)||void 0===t?void 0:t.call(e,n)}))},onActive:function(n){e.onActive(n),t.forEach((function(e){var t;return null===(t=e.onActive)||void 0===t?void 0:t.call(e,n)}))},onResolve:function(n){e.onResolve(n),t.forEach((function(e){var t;return null===(t=e.onResolve)||void 0===t?void 0:t.call(e,n)}))}})}))}))},navigator:nn({navigate:function(e){var t=e.itemUrl;r.location.assign(t)},navigateNewTab:function(e){var t=e.itemUrl,n=r.open(t,"_blank","noopener");null==n||n.focus()},navigateNewWindow:function(e){var t=e.itemUrl;r.open(t,"_blank","noopener")}},e.navigator)})}function cn(e){return cn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},cn(e)}function an(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function un(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);n=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}(e,bn);Pn&&o.environment.clearTimeout(Pn);var l=u.setCollections,s=u.setIsOpen,f=u.setQuery,p=u.setActiveItemId,m=u.setStatus;if(f(i),p(o.defaultActiveItemId),!i&&!1===o.openOnFocus){var d,v=a.getState().collections.map((function(e){return Sn(Sn({},e),{},{items:[]})}));m("idle"),l(v),s(null!==(d=r.isOpen)&&void 0!==d?d:o.shouldPanelOpen({state:a.getState()}));var h=Lt(In(v).then((function(){return Promise.resolve()})));return a.pendingRequests.add(h)}m("loading"),Pn=o.environment.setTimeout((function(){m("stalled")}),o.stallThreshold);var y=Lt(In(o.getSources(Sn({query:i,refresh:c,state:a.getState()},u)).then((function(e){return Promise.all(e.map((function(e){return Promise.resolve(e.getItems(Sn({query:i,refresh:c,state:a.getState()},u))).then((function(t){return function(e,t,n){if(o=e,Boolean(null==o?void 0:o.execute)){var r="algolia"===e.requesterId?Object.assign.apply(Object,[{}].concat(dn(Object.keys(n.context).map((function(e){var t;return null===(t=n.context[e])||void 0===t?void 0:t.__algoliaSearchParameters}))))):{};return pn(pn({},e),{},{requests:e.queries.map((function(n){return{query:"algolia"===e.requesterId?pn(pn({},n),{},{params:pn(pn({},r),n.params)}):n,sourceId:t,transformResponse:e.transformResponse}}))})}var o;return{items:e,sourceId:t}}(t,e.sourceId,a.getState())}))}))).then(yn).then((function(t){return function(e,t,n){return t.map((function(t){var r,o=e.filter((function(e){return e.sourceId===t.sourceId})),i=o.map((function(e){return e.items})),c=o[0].transformResponse,a=c?c({results:r=i,hits:r.map((function(e){return e.hits})).filter(Boolean),facetHits:r.map((function(e){var t;return null===(t=e.facetHits)||void 0===t?void 0:t.map((function(e){return{label:e.value,count:e.count,_highlightResult:{label:{value:e.highlighted}}}}))})).filter(Boolean)}):i;return t.onResolve({source:t,results:i,items:a,state:n.getState()}),a.every(Boolean),'The `getItems` function from source "'.concat(t.sourceId,'" must return an array of items but returned ').concat(JSON.stringify(void 0),".\n\nDid you forget to return items?\n\nSee: https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-getitems"),{source:t,items:a}}))}(t,e,a)})).then((function(e){return function(e){var t=e.props,n=e.state,r=e.collections.reduce((function(e,t){return un(un({},e),{},ln({},t.source.sourceId,un(un({},t.source),{},{getItems:function(){return ot(t.items)}})))}),{}),o=t.plugins.reduce((function(e,t){return t.reshape?t.reshape(e):e}),{sourcesBySourceId:r,state:n}).sourcesBySourceId;return ot(t.reshape({sourcesBySourceId:o,sources:Object.values(o),state:n})).filter(Boolean).map((function(e){return{source:e,items:e.getItems()}}))}({collections:e,props:o,state:a.getState()})}))})))).then((function(e){var n;m("idle"),l(e);var f=o.shouldPanelOpen({state:a.getState()});s(null!==(n=r.isOpen)&&void 0!==n?n:o.openOnFocus&&!i&&f||f);var p=Kt(a.getState());if(null!==a.getState().activeItemId&&p){var d=p.item,v=p.itemInputValue,h=p.itemUrl,y=p.source;y.onActive(Sn({event:t,item:d,itemInputValue:v,itemUrl:h,refresh:c,source:y,state:a.getState()},u))}})).finally((function(){m("idle"),Pn&&o.environment.clearTimeout(Pn)}));return a.pendingRequests.add(y)}function kn(e){return kn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},kn(e)}var Cn=["event","props","refresh","store"];function An(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function xn(e){for(var t=1;t=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function zn(e){var t=e.props,n=e.refresh,r=e.store,o=Wn(e,Rn),i=function(e,t){return void 0!==t?"".concat(e,"-").concat(t):e};return{getEnvironmentProps:function(e){var n=e.inputElement,o=e.formElement,i=e.panelElement;function c(e){!r.getState().isOpen&&r.pendingRequests.isEmpty()||e.target===n||!1===[o,i].some((function(t){return(n=t)===(r=e.target)||n.contains(r);var n,r}))&&(r.dispatch("blur",null),t.debug||r.pendingRequests.cancelAll())}return Vn({onTouchStart:c,onMouseDown:c,onTouchMove:function(e){!1!==r.getState().isOpen&&n===t.environment.document.activeElement&&e.target!==n&&n.blur()}},Wn(e,qn))},getRootProps:function(e){return Vn({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-owns":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label")},e)},getFormProps:function(e){return e.inputElement,Vn({action:"",noValidate:!0,role:"search",onSubmit:function(i){var c;i.preventDefault(),t.onSubmit(Vn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),null===(c=e.inputElement)||void 0===c||c.blur()},onReset:function(i){var c;i.preventDefault(),t.onReset(Vn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),null===(c=e.inputElement)||void 0===c||c.focus()}},Wn(e,Ln))},getLabelProps:function(e){var n=e||{},r=n.sourceIndex,o=Wn(n,Hn);return Vn({htmlFor:"".concat(i(t.id,r),"-input"),id:"".concat(i(t.id,r),"-label")},o)},getInputProps:function(e){var i;function c(e){(t.openOnFocus||Boolean(r.getState().query))&&Dn(Vn({event:e,props:t,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var a=e||{},u=(a.inputElement,a.maxLength),l=void 0===u?512:u,s=Wn(a,Mn),f=Kt(r.getState()),p=function(e){return Boolean(e&&e.match(Wt))}((null===(i=t.environment.navigator)||void 0===i?void 0:i.userAgent)||""),m=null!=f&&f.itemUrl&&!p?"go":"search";return Vn({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&null!==r.getState().activeItemId?"".concat(t.id,"-item-").concat(r.getState().activeItemId):void 0,"aria-controls":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label"),value:r.getState().completion||r.getState().query,id:"".concat(t.id,"-input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:m,spellCheck:"false",autoFocus:t.autoFocus,placeholder:t.placeholder,maxLength:l,type:"search",onChange:function(e){Dn(Vn({event:e,props:t,query:e.currentTarget.value.slice(0,l),refresh:n,store:r},o))},onKeyDown:function(e){!function(e){var t=e.event,n=e.props,r=e.refresh,o=e.store,i=function(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}(e,Cn);if("ArrowUp"===t.key||"ArrowDown"===t.key){var c=function(){var e=n.environment.document.getElementById("".concat(n.id,"-item-").concat(o.getState().activeItemId));e&&(e.scrollIntoViewIfNeeded?e.scrollIntoViewIfNeeded(!1):e.scrollIntoView(!1))},a=function(){var e=Kt(o.getState());if(null!==o.getState().activeItemId&&e){var n=e.item,c=e.itemInputValue,a=e.itemUrl,u=e.source;u.onActive(xn({event:t,item:n,itemInputValue:c,itemUrl:a,refresh:r,source:u,state:o.getState()},i))}};t.preventDefault(),!1===o.getState().isOpen&&(n.openOnFocus||Boolean(o.getState().query))?Dn(xn({event:t,props:n,query:o.getState().query,refresh:r,store:o},i)).then((function(){o.dispatch(t.key,{nextActiveItemId:n.defaultActiveItemId}),a(),setTimeout(c,0)})):(o.dispatch(t.key,{}),a(),c())}else if("Escape"===t.key)t.preventDefault(),o.dispatch(t.key,null),o.pendingRequests.cancelAll();else if("Tab"===t.key)o.dispatch("blur",null),o.pendingRequests.cancelAll();else if("Enter"===t.key){if(null===o.getState().activeItemId||o.getState().collections.every((function(e){return 0===e.items.length})))return void(n.debug||o.pendingRequests.cancelAll());t.preventDefault();var u=Kt(o.getState()),l=u.item,s=u.itemInputValue,f=u.itemUrl,p=u.source;if(t.metaKey||t.ctrlKey)void 0!==f&&(p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewTab({itemUrl:f,item:l,state:o.getState()}));else if(t.shiftKey)void 0!==f&&(p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewWindow({itemUrl:f,item:l,state:o.getState()}));else if(t.altKey);else{if(void 0!==f)return p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),void n.navigator.navigate({itemUrl:f,item:l,state:o.getState()});Dn(xn({event:t,nextState:{isOpen:!1},props:n,query:s,refresh:r,store:o},i)).then((function(){p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i))}))}}}(Vn({event:e,props:t,refresh:n,store:r},o))},onFocus:c,onBlur:lt,onClick:function(n){e.inputElement!==t.environment.document.activeElement||r.getState().isOpen||c(n)}},s)},getPanelProps:function(e){return Vn({onMouseDown:function(e){e.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},e)},getListProps:function(e){var n=e||{},r=n.sourceIndex,o=Wn(n,Un);return Vn({role:"listbox","aria-labelledby":"".concat(i(t.id,r),"-label"),id:"".concat(i(t.id,r),"-list")},o)},getItemProps:function(e){var c=e.item,a=e.source,u=e.sourceIndex,l=Wn(e,Fn);return Vn({id:"".concat(i(t.id,u),"-item-").concat(c.__autocomplete_id),role:"option","aria-selected":r.getState().activeItemId===c.__autocomplete_id,onMouseMove:function(e){if(c.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",c.__autocomplete_id);var t=Kt(r.getState());if(null!==r.getState().activeItemId&&t){var i=t.item,a=t.itemInputValue,u=t.itemUrl,l=t.source;l.onActive(Vn({event:e,item:i,itemInputValue:a,itemUrl:u,refresh:n,source:l,state:r.getState()},o))}}},onMouseDown:function(e){e.preventDefault()},onClick:function(e){var i=a.getItemInputValue({item:c,state:r.getState()}),u=a.getItemUrl({item:c,state:r.getState()});(u?Promise.resolve():Dn(Vn({event:e,nextState:{isOpen:!1},props:t,query:i,refresh:n,store:r},o))).then((function(){a.onSelect(Vn({event:e,item:c,itemInputValue:i,itemUrl:u,refresh:n,source:a,state:r.getState()},o))}))}},l)}}}function Jn(e){return Jn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Jn(e)}function $n(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Zn(e){for(var t=1;t0&&Be.createElement("div",{className:"DocSearch-NoResults-Prefill-List"},Be.createElement("p",{className:"DocSearch-Help"},a,":"),Be.createElement("ul",null,p.slice(0,3).reduce((function(e,t){return[].concat(function(e){return function(e){if(Array.isArray(e))return Ye(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||Qe(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(e),[Be.createElement("li",{key:t},Be.createElement("button",{className:"DocSearch-Prefill",key:t,type:"button",onClick:function(){r.setQuery(t.toLowerCase()+" "),r.refresh(),r.inputRef.current.focus()}},t))])}),[]))),r.getMissingResultsUrl&&Be.createElement("p",{className:"DocSearch-Help"},"".concat(l," "),Be.createElement("a",{href:r.getMissingResultsUrl({query:r.state.query}),target:"_blank",rel:"noopener noreferrer"},f)))}var Ir=["hit","attribute","tagName"];function Dr(e,t){return t.split(".").reduce((function(e,t){return null!=e&&e[t]?e[t]:null}),e)}function kr(e){var t=e.hit,n=e.attribute,r=e.tagName;return g(void 0===r?"span":r,We(We({},$e(e,Ir)),{},{dangerouslySetInnerHTML:{__html:Dr(t,"_snippetResult.".concat(n,".value"))||Dr(t,n)}}))}function Cr(e){return e.collection&&0!==e.collection.items.length?Be.createElement("section",{className:"DocSearch-Hits"},Be.createElement("div",{className:"DocSearch-Hit-source"},e.title),Be.createElement("ul",e.getListProps(),e.collection.items.map((function(t,n){return Be.createElement(Ar,Je({key:[e.title,t.objectID].join(":"),item:t,index:n},e))})))):null}function Ar(e){var t=e.item,n=e.index,r=e.renderIcon,o=e.renderAction,i=e.getItemProps,c=e.onItemClick,a=e.collection,u=e.hitComponent,l=Ze(Be.useState(!1),2),s=l[0],f=l[1],p=Ze(Be.useState(!1),2),m=p[0],d=p[1],v=Be.useRef(null),h=u;return Be.createElement("li",Je({className:["DocSearch-Hit",t.__docsearch_parent&&"DocSearch-Hit--Child",s&&"DocSearch-Hit--deleting",m&&"DocSearch-Hit--favoriting"].filter(Boolean).join(" "),onTransitionEnd:function(){v.current&&v.current()}},i({item:t,source:a.source,onClick:function(e){c(t,e)}})),Be.createElement(h,{hit:t},Be.createElement("div",{className:"DocSearch-Hit-Container"},r({item:t,index:n}),t.hierarchy[t.type]&&"lvl1"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.lvl1"}),t.content&&Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"content"})),t.hierarchy[t.type]&&("lvl2"===t.type||"lvl3"===t.type||"lvl4"===t.type||"lvl5"===t.type||"lvl6"===t.type)&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.".concat(t.type)}),Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),"content"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"content"}),Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),o({item:t,runDeleteTransition:function(e){f(!0),v.current=e},runFavoriteTransition:function(e){d(!0),v.current=e}}))))}function xr(e,t,n){return e.reduce((function(e,r){var o=t(r);return e.hasOwnProperty(o)||(e[o]=[]),e[o].length<(n||5)&&e[o].push(r),e}),{})}function Nr(e){return e}function Tr(e){return 1===e.button||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey}function Rr(){}var qr=/(|<\/mark>)/g,Lr=RegExp(qr.source);function Mr(e){var t,n,r=e;if(!r.__docsearch_parent&&!e._highlightResult)return e.hierarchy.lvl0;var o=((r.__docsearch_parent?null===(t=r.__docsearch_parent)||void 0===t||null===(t=t._highlightResult)||void 0===t||null===(t=t.hierarchy)||void 0===t?void 0:t.lvl0:null===(n=e._highlightResult)||void 0===n||null===(n=n.hierarchy)||void 0===n?void 0:n.lvl0)||{}).value;return o&&Lr.test(o)?o.replace(qr,""):o}function Hr(e){return Be.createElement("div",{className:"DocSearch-Dropdown-Container"},e.state.collections.map((function(t){if(0===t.items.length)return null;var n=Mr(t.items[0]);return Be.createElement(Cr,Je({},e,{key:t.source.sourceId,title:n,collection:t,renderIcon:function(e){var n,r=e.item,o=e.index;return Be.createElement(Be.Fragment,null,r.__docsearch_parent&&Be.createElement("svg",{className:"DocSearch-Hit-Tree",viewBox:"0 0 24 54"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},r.__docsearch_parent!==(null===(n=t.items[o+1])||void 0===n?void 0:n.__docsearch_parent)?Be.createElement("path",{d:"M8 6v21M20 27H8.3"}):Be.createElement("path",{d:"M8 6v42M20 27H8.3"}))),Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(_r,{type:r.type})))},renderAction:function(){return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement(hr,null))}}))})),e.resultsFooterComponent&&Be.createElement("section",{className:"DocSearch-HitsFooter"},Be.createElement(e.resultsFooterComponent,{state:e.state})))}var Ur=["translations"];function Fr(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Ur),o=n.recentSearchesTitle,i=void 0===o?"Recent":o,c=n.noRecentSearchesText,a=void 0===c?"No recent searches":c,u=n.saveRecentSearchButtonTitle,l=void 0===u?"Save this search":u,s=n.removeRecentSearchButtonTitle,f=void 0===s?"Remove this search from history":s,p=n.favoriteSearchesTitle,m=void 0===p?"Favorite":p,d=n.removeFavoriteSearchButtonTitle,v=void 0===d?"Remove this search from favorites":d;return"idle"===r.state.status&&!1===r.hasCollections?r.disableUserPersonalization?null:Be.createElement("div",{className:"DocSearch-StartScreen"},Be.createElement("p",{className:"DocSearch-Help"},a)):!1===r.hasCollections?null:Be.createElement("div",{className:"DocSearch-Dropdown-Container"},Be.createElement(Cr,Je({},r,{title:i,collection:r.state.collections[0],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(dr,null))},renderAction:function(e){var t=e.item,n=e.runFavoriteTransition,o=e.runDeleteTransition;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:l,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.add(t),r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(Sr,null))),Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:f,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),o((function(){r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(vr,null))))}})),Be.createElement(Cr,Je({},r,{title:m,collection:r.state.collections[1],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(Sr,null))},renderAction:function(e){var t=e.item,n=e.runDeleteTransition;return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:v,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.remove(t),r.refresh()}))}},Be.createElement(vr,null)))}})))}var Br=["translations"],Vr=Be.memo((function(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Br);if("error"===r.state.status)return Be.createElement(Er,{translations:null==n?void 0:n.errorScreen});var o=r.state.collections.some((function(e){return e.items.length>0}));return r.state.query?!1===o?Be.createElement(Pr,Je({},r,{translations:null==n?void 0:n.noResultsScreen})):Be.createElement(Hr,r):Be.createElement(Fr,Je({},r,{hasCollections:o,translations:null==n?void 0:n.startScreen}))}),(function(e,t){return"loading"===t.state.status||"stalled"===t.state.status})),Kr=["translations"];function Wr(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Kr),o=n.resetButtonTitle,i=void 0===o?"Clear the query":o,c=n.resetButtonAriaLabel,a=void 0===c?"Clear the query":c,u=n.cancelButtonText,l=void 0===u?"Cancel":u,s=n.cancelButtonAriaLabel,f=void 0===s?"Cancel":s,p=n.searchInputLabel,m=void 0===p?"Search":p,d=r.getFormProps({inputElement:r.inputRef.current}).onReset;return Be.useEffect((function(){r.autoFocus&&r.inputRef.current&&r.inputRef.current.focus()}),[r.autoFocus,r.inputRef]),Be.useEffect((function(){r.isFromSelection&&r.inputRef.current&&r.inputRef.current.select()}),[r.isFromSelection,r.inputRef]),Be.createElement(Be.Fragment,null,Be.createElement("form",{className:"DocSearch-Form",onSubmit:function(e){e.preventDefault()},onReset:d},Be.createElement("label",Je({className:"DocSearch-MagnifierLabel"},r.getLabelProps()),Be.createElement(Xe,null),Be.createElement("span",{className:"DocSearch-VisuallyHiddenForAccessibility"},m)),Be.createElement("div",{className:"DocSearch-LoadingIndicator"},Be.createElement(mr,null)),Be.createElement("input",Je({className:"DocSearch-Input",ref:r.inputRef},r.getInputProps({inputElement:r.inputRef.current,autoFocus:r.autoFocus,maxLength:64}))),Be.createElement("button",{type:"reset",title:i,className:"DocSearch-Reset","aria-label":a,hidden:!r.state.query},Be.createElement(vr,null))),Be.createElement("button",{className:"DocSearch-Cancel",type:"reset","aria-label":f,onClick:r.onClose},l))}var zr=["_highlightResult","_snippetResult"];function Jr(e){var t=e.key,n=e.limit,r=void 0===n?5:n,o=function(e){return!1===function(){var e="__TEST_KEY__";try{return localStorage.setItem(e,""),localStorage.removeItem(e),!0}catch(e){return!1}}()?{setItem:function(){},getItem:function(){return[]}}:{setItem:function(t){return window.localStorage.setItem(e,JSON.stringify(t))},getItem:function(){var t=window.localStorage.getItem(e);return t?JSON.parse(t):[]}}}(t),i=o.getItem().slice(0,r);return{add:function(e){var t=e,n=(t._highlightResult,t._snippetResult,$e(t,zr)),c=i.findIndex((function(e){return e.objectID===n.objectID}));c>-1&&i.splice(c,1),i.unshift(n),i=i.slice(0,r),o.setItem(i)},remove:function(e){i=i.filter((function(t){return t.objectID!==e.objectID})),o.setItem(i)},getAll:function(){return i}}}function $r(e){var t,n="algoliasearch-client-js-".concat(e.key),r=function(){return void 0===t&&(t=e.localStorage||window.localStorage),t},o=function(){return JSON.parse(r().getItem(n)||"{}")},i=function(e){r().setItem(n,JSON.stringify(e))};return{get:function(t,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then((function(){!function(){var t=e.timeToLive?1e3*e.timeToLive:null,n=o(),r=Object.fromEntries(Object.entries(n).filter((function(e){return void 0!==c(e,2)[1].timestamp})));if(i(r),t){var a=Object.fromEntries(Object.entries(r).filter((function(e){var n=c(e,2)[1],r=(new Date).getTime();return!(n.timestamp+t2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return t().then((function(e){return Promise.all([e,n.miss(e)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve(t)},delete:function(e){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(e,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return n.get(e,r,o).catch((function(){return Zr({caches:t}).get(e,r,o)}))},set:function(e,r){return n.set(e,r).catch((function(){return Zr({caches:t}).set(e,r)}))},delete:function(e){return n.delete(e).catch((function(){return Zr({caches:t}).delete(e)}))},clear:function(){return n.clear().catch((function(){return Zr({caches:t}).clear()}))}}}function Qr(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{serializable:!0},t={};return{get:function(n,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},i=JSON.stringify(n);if(i in t)return Promise.resolve(e.serializable?JSON.parse(t[i]):t[i]);var c=r(),a=o&&o.miss||function(){return Promise.resolve()};return c.then((function(e){return a(e)})).then((function(){return c}))},set:function(n,r){return t[JSON.stringify(n)]=e.serializable?JSON.stringify(r):r,Promise.resolve(r)},delete:function(e){return delete t[JSON.stringify(e)],Promise.resolve()},clear:function(){return t={},Promise.resolve()}}}function Yr(e){for(var t=e.length-1;t>0;t--){var n=Math.floor(Math.random()*(t+1)),r=e[t];e[t]=e[n],e[n]=r}return e}function Gr(e,t){return t?(Object.keys(t).forEach((function(n){e[n]=t[n](e)})),e):e}function Xr(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r0?r:void 0,timeout:n.timeout||t,headers:n.headers||{},queryParameters:n.queryParameters||{},cacheable:n.cacheable}}var ro={Read:1,Write:2,Any:3};function oo(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1;return t(t({},e),{},{status:n,lastUpdate:Date.now()})}function io(e){return"string"==typeof e?{protocol:"https",url:e,accept:ro.Any}:{protocol:e.protocol||"https",url:e.url,accept:e.accept||ro.Any}}var co="GET",ao="POST";function uo(e,n,r,o){var i=[],c=function(e,n){if(e.method!==co&&(void 0!==e.data||void 0!==n.data)){var r=Array.isArray(e.data)?e.data:t(t({},e.data),n.data);return JSON.stringify(r)}}(r,o),u=function(e,n){var r=t(t({},e.headers),n.headers),o={};return Object.keys(r).forEach((function(e){var t=r[e];o[e.toLowerCase()]=t})),o}(e,o),l=r.method,s=r.method!==co?{}:t(t({},r.data),o.data),f=t(t(t({"x-algolia-agent":e.userAgent.value},e.queryParameters),s),o.queryParameters),p=0,m=function t(n,a){var s=n.pop();if(void 0===s)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:po(i)};var m={data:c,headers:u,method:l,url:so(s,r.path,f),connectTimeout:a(p,e.timeouts.connect),responseTimeout:a(p,o.timeout)},d=function(e){var t={request:m,response:e,host:s,triesLeft:n.length};return i.push(t),t},v={onSuccess:function(e){return function(e){try{return JSON.parse(e.content)}catch(t){throw function(e,t){return{name:"DeserializationError",message:e,response:t}}(t.message,e)}}(e)},onRetry:function(r){var o=d(r);return r.isTimedOut&&p++,Promise.all([e.logger.info("Retryable failure",mo(o)),e.hostsCache.set(s,oo(s,r.isTimedOut?3:2))]).then((function(){return t(n,a)}))},onFail:function(e){throw d(e),function(e,t){var n=e.content,r=e.status,o=n;try{o=JSON.parse(n).message}catch(n){}return function(e,t,n){return{name:"ApiError",message:e,status:t,transporterStackTrace:n}}(o,r,t)}(e,po(i))}};return e.requester.send(m).then((function(e){return function(e,t){return function(e){var t=e.status;return e.isTimedOut||function(e){var t=e.isTimedOut,n=e.status;return!t&&0==~~n}(e)||2!=~~(t/100)&&4!=~~(t/100)}(e)?t.onRetry(e):(n=e,2==~~(n.status/100)?t.onSuccess(e):t.onFail(e));var n}(e,v)}))};return function(e,t){return Promise.all(t.map((function(t){return e.get(t,(function(){return Promise.resolve(oo(t))}))}))).then((function(e){var n=e.filter((function(e){return function(e){return 1===e.status||Date.now()-e.lastUpdate>12e4}(e)})),r=e.filter((function(e){return function(e){return 3===e.status&&Date.now()-e.lastUpdate<=12e4}(e)})),o=[].concat(a(n),a(r));return{getTimeout:function(e,t){return(0===r.length&&0===e?1:r.length+3+e)*t},statelessHosts:o.length>0?o.map((function(e){return io(e)})):t}}))}(e.hostsCache,n).then((function(e){return m(a(e.statelessHosts).reverse(),e.getTimeout)}))}function lo(e){var t={value:"Algolia for JavaScript (".concat(e,")"),add:function(e){var n="; ".concat(e.segment).concat(void 0!==e.version?" (".concat(e.version,")"):"");return-1===t.value.indexOf(n)&&(t.value="".concat(t.value).concat(n)),t}};return t}function so(e,t,n){var r=fo(n),o="".concat(e.protocol,"://").concat(e.url,"/").concat("/"===t.charAt(0)?t.substr(1):t);return r.length&&(o+="?".concat(r)),o}function fo(e){return Object.keys(e).map((function(t){return Xr("%s=%s",t,(n=e[t],"[object Object]"===Object.prototype.toString.call(n)||"[object Array]"===Object.prototype.toString.call(n)?JSON.stringify(e[t]):e[t]));var n})).join("&")}function po(e){return e.map((function(e){return mo(e)}))}function mo(e){var n=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return t(t({},e),{},{request:t(t({},e.request),{},{headers:t(t({},e.request.headers),n)})})}var vo=function(e){return function(t,n){return t.method===co?e.transporter.read(t,n):e.transporter.write(t,n)}},ho=function(e){return function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return Gr({transporter:e.transporter,appId:e.appId,indexName:t},n.methods)}},yo=function(e){return function(n,r){var o=n.map((function(e){return t(t({},e),{},{params:fo(e.params||{})})}));return e.transporter.read({method:ao,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)}},_o=function(e){return function(n,r){return Promise.all(n.map((function(n){var o=n.params,c=o.facetName,a=o.facetQuery,u=i(o,Ve);return ho(e)(n.indexName,{methods:{searchForFacetValues:So}}).searchForFacetValues(c,a,t(t({},r),u))})))}},bo=function(e){return function(t,n,r){return e.transporter.read({method:ao,path:Xr("1/answers/%s/prediction",e.indexName),data:{query:t,queryLanguages:n},cacheable:!0},r)}},go=function(e){return function(t,n){return e.transporter.read({method:ao,path:Xr("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},n)}},So=function(e){return function(t,n,r){return e.transporter.read({method:ao,path:Xr("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:n},cacheable:!0},r)}};function Oo(e,n,r){var o={appId:e,apiKey:n,timeouts:{connect:1,read:2,write:30},requester:{send:function(e){return new Promise((function(t){var n=new XMLHttpRequest;n.open(e.method,e.url,!0),Object.keys(e.headers).forEach((function(t){return n.setRequestHeader(t,e.headers[t])}));var r,o=function(e,r){return setTimeout((function(){n.abort(),t({status:0,content:r,isTimedOut:!0})}),1e3*e)},i=o(e.connectTimeout,"Connection timeout");n.onreadystatechange=function(){n.readyState>n.OPENED&&void 0===r&&(clearTimeout(i),r=o(e.responseTimeout,"Socket timeout"))},n.onerror=function(){0===n.status&&(clearTimeout(i),clearTimeout(r),t({content:n.responseText||"Network request failed",status:n.status,isTimedOut:!1}))},n.onload=function(){clearTimeout(i),clearTimeout(r),t({content:n.responseText,status:n.status,isTimedOut:!1})},n.send(e.data)}))}},logger:(3,{debug:function(e,t){return Promise.resolve()},info:function(e,t){return Promise.resolve()},error:function(e,t){return console.error(e,t),Promise.resolve()}}),responsesCache:Qr(),requestsCache:Qr({serializable:!1}),hostsCache:Zr({caches:[$r({key:"4.19.1-".concat(e)}),Qr()]}),userAgent:lo("4.19.1").add({segment:"Browser",version:"lite"}),authMode:eo};return function(e){var n=e.appId,r=function(e,t,n){var r={"x-algolia-api-key":n,"x-algolia-application-id":t};return{headers:function(){return e===to?r:{}},queryParameters:function(){return e===eo?r:{}}}}(void 0!==e.authMode?e.authMode:to,n,e.apiKey),o=function(e){var t=e.hostsCache,n=e.logger,r=e.requester,o=e.requestsCache,i=e.responsesCache,a=e.timeouts,u=e.userAgent,l=e.hosts,s=e.queryParameters,f={hostsCache:t,logger:n,requester:r,requestsCache:o,responsesCache:i,timeouts:a,userAgent:u,headers:e.headers,queryParameters:s,hosts:l.map((function(e){return io(e)})),read:function(e,t){var n=no(t,f.timeouts.read),r=function(){return uo(f,f.hosts.filter((function(e){return 0!=(e.accept&ro.Read)})),e,n)};if(!0!==(void 0!==n.cacheable?n.cacheable:e.cacheable))return r();var o={request:e,mappedRequestOptions:n,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(o,(function(){return f.requestsCache.get(o,(function(){return f.requestsCache.set(o,r()).then((function(e){return Promise.all([f.requestsCache.delete(o),e])}),(function(e){return Promise.all([f.requestsCache.delete(o),Promise.reject(e)])})).then((function(e){var t=c(e,2);return t[0],t[1]}))}))}),{miss:function(e){return f.responsesCache.set(o,e)}})},write:function(e,t){return uo(f,f.hosts.filter((function(e){return 0!=(e.accept&ro.Write)})),e,no(t,f.timeouts.write))}};return f}(t(t({hosts:[{url:"".concat(n,"-dsn.algolia.net"),accept:ro.Read},{url:"".concat(n,".algolia.net"),accept:ro.Write}].concat(Yr([{url:"".concat(n,"-1.algolianet.com")},{url:"".concat(n,"-2.algolianet.com")},{url:"".concat(n,"-3.algolianet.com")}]))},e),{},{headers:t(t({},r.headers()),{},{"content-type":"application/x-www-form-urlencoded"},e.headers),queryParameters:t(t({},r.queryParameters()),e.queryParameters)})),i={transporter:o,appId:n,addAlgoliaAgent:function(e,t){o.userAgent.add({segment:e,version:t})},clearCache:function(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then((function(){}))}};return Gr(i,e.methods)}(t(t(t({},o),r),{},{methods:{search:yo,searchForFacetValues:_o,multipleQueries:yo,multipleSearchForFacetValues:_o,customRequest:vo,initIndex:function(e){return function(t){return ho(e)(t,{methods:{search:go,searchForFacetValues:So,findAnswers:bo}})}}}}))}Oo.version="4.19.1";var wo=["footer","searchBox"];function Eo(e){var t=e.appId,n=e.apiKey,r=e.indexName,o=e.placeholder,i=void 0===o?"Search docs":o,c=e.searchParameters,a=e.maxResultsPerGroup,u=e.onClose,l=void 0===u?Rr:u,s=e.transformItems,f=void 0===s?Nr:s,p=e.hitComponent,m=void 0===p?pr:p,d=e.resultsFooterComponent,v=void 0===d?function(){return null}:d,h=e.navigator,y=e.initialScrollY,_=void 0===y?0:y,b=e.transformSearchClient,g=void 0===b?Nr:b,S=e.disableUserPersonalization,O=void 0!==S&&S,w=e.initialQuery,E=void 0===w?"":w,j=e.translations,P=void 0===j?{}:j,I=e.getMissingResultsUrl,D=e.insights,k=void 0!==D&&D,C=P.footer,A=P.searchBox,x=$e(P,wo),N=Ze(Be.useState({query:"",collections:[],completion:null,context:{},isOpen:!1,activeItemId:null,status:"idle"}),2),T=N[0],R=N[1],q=Be.useRef(null),L=Be.useRef(null),M=Be.useRef(null),H=Be.useRef(null),U=Be.useRef(null),F=Be.useRef(10),B=Be.useRef("undefined"!=typeof window?window.getSelection().toString().slice(0,64):"").current,V=Be.useRef(E||B).current,K=function(e,t,n){return Be.useMemo((function(){var r=Oo(e,t);return r.addAlgoliaAgent("docsearch","3.6.1"),!1===/docsearch.js \(.*\)/.test(r.transporter.userAgent.value)&&r.addAlgoliaAgent("docsearch-react","3.6.1"),n(r)}),[e,t,n])}(t,n,g),W=Be.useRef(Jr({key:"__DOCSEARCH_FAVORITE_SEARCHES__".concat(r),limit:10})).current,z=Be.useRef(Jr({key:"__DOCSEARCH_RECENT_SEARCHES__".concat(r),limit:0===W.getAll().length?7:4})).current,J=Be.useCallback((function(e){if(!O){var t="content"===e.type?e.__docsearch_parent:e;t&&-1===W.getAll().findIndex((function(e){return e.objectID===t.objectID}))&&z.add(t)}}),[W,z,O]),$=Be.useCallback((function(e){if(T.context.algoliaInsightsPlugin&&e.__autocomplete_id){var t=e,n={eventName:"Item Selected",index:t.__autocomplete_indexName,items:[t],positions:[e.__autocomplete_id],queryID:t.__autocomplete_queryID};T.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(n)}}),[T.context.algoliaInsightsPlugin]),Z=Be.useMemo((function(){return ur({id:"docsearch",defaultActiveItemId:0,placeholder:i,openOnFocus:!0,initialState:{query:V,context:{searchSuggestions:[]}},insights:k,navigator:h,onStateChange:function(e){R(e.state)},getSources:function(e){var o=e.query,i=e.state,u=e.setContext,s=e.setStatus;if(!o)return O?[]:[{sourceId:"recentSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Tr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return z.getAll()}},{sourceId:"favoriteSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Tr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return W.getAll()}}];var p=Boolean(k);return K.search([{query:o,indexName:r,params:We({attributesToRetrieve:["hierarchy.lvl0","hierarchy.lvl1","hierarchy.lvl2","hierarchy.lvl3","hierarchy.lvl4","hierarchy.lvl5","hierarchy.lvl6","content","type","url"],attributesToSnippet:["hierarchy.lvl1:".concat(F.current),"hierarchy.lvl2:".concat(F.current),"hierarchy.lvl3:".concat(F.current),"hierarchy.lvl4:".concat(F.current),"hierarchy.lvl5:".concat(F.current),"hierarchy.lvl6:".concat(F.current),"content:".concat(F.current)],snippetEllipsisText:"…",highlightPreTag:"",highlightPostTag:"",hitsPerPage:20,clickAnalytics:p},c)}]).catch((function(e){throw"RetryError"===e.name&&s("error"),e})).then((function(e){var o=e.results[0],c=o.hits,s=o.nbHits,m=xr(c,(function(e){return Mr(e)}),a);i.context.searchSuggestions.length0&&(G(),U.current&&U.current.focus())}),[V,G]),Be.useEffect((function(){function e(){if(L.current){var e=.01*window.innerHeight;L.current.style.setProperty("--docsearch-vh","".concat(e,"px"))}}return e(),window.addEventListener("resize",e),function(){window.removeEventListener("resize",e)}}),[]),Be.createElement("div",Je({ref:q},Y({"aria-expanded":!0}),{className:["DocSearch","DocSearch-Container","stalled"===T.status&&"DocSearch-Container--Stalled","error"===T.status&&"DocSearch-Container--Errored"].filter(Boolean).join(" "),role:"button",tabIndex:0,onMouseDown:function(e){e.target===e.currentTarget&&l()}}),Be.createElement("div",{className:"DocSearch-Modal",ref:L},Be.createElement("header",{className:"DocSearch-SearchBar",ref:M},Be.createElement(Wr,Je({},Z,{state:T,autoFocus:0===V.length,inputRef:U,isFromSelection:Boolean(V)&&V===B,translations:A,onClose:l}))),Be.createElement("div",{className:"DocSearch-Dropdown",ref:H},Be.createElement(Vr,Je({},Z,{indexName:r,state:T,hitComponent:m,resultsFooterComponent:v,disableUserPersonalization:O,recentSearches:z,favoriteSearches:W,inputRef:U,translations:x,getMissingResultsUrl:I,onItemClick:function(e,t){$(e),J(e),Tr(t)||l()}}))),Be.createElement("footer",{className:"DocSearch-Footer"},Be.createElement(fr,{translations:C}))))}function jo(e){var t,n,r=Be.useRef(null),o=Ze(Be.useState(!1),2),i=o[0],c=o[1],a=Ze(Be.useState((null==e?void 0:e.initialQuery)||void 0),2),u=a[0],l=a[1],s=Be.useCallback((function(){c(!0)}),[c]),f=Be.useCallback((function(){c(!1)}),[c]);return function(e){var t=e.isOpen,n=e.onOpen,r=e.onClose,o=e.onInput,i=e.searchButtonRef;Be.useEffect((function(){function e(e){var c;(27===e.keyCode&&t||"k"===(null===(c=e.key)||void 0===c?void 0:c.toLowerCase())&&(e.metaKey||e.ctrlKey)||!function(e){var t=e.target,n=t.tagName;return t.isContentEditable||"INPUT"===n||"SELECT"===n||"TEXTAREA"===n}(e)&&"/"===e.key&&!t)&&(e.preventDefault(),t?r():document.body.classList.contains("DocSearch--active")||document.body.classList.contains("DocSearch--active")||n()),i&&i.current===document.activeElement&&o&&/[a-zA-Z0-9]/.test(String.fromCharCode(e.keyCode))&&o(e)}return window.addEventListener("keydown",e),function(){window.removeEventListener("keydown",e)}}),[t,n,r,o,i])}({isOpen:i,onOpen:s,onClose:f,onInput:Be.useCallback((function(e){c(!0),l(e.key)}),[c,l]),searchButtonRef:r}),Be.createElement(Be.Fragment,null,Be.createElement(tt,{ref:r,translations:null==e||null===(t=e.translations)||void 0===t?void 0:t.button,onClick:s}),i&&Ie(Be.createElement(Eo,Je({},e,{initialScrollY:window.scrollY,initialQuery:u,translations:null==e||null===(n=e.translations)||void 0===n?void 0:n.modal,onClose:f})),document.body))}return function(e){Ae(Be.createElement(jo,o({},e,{transformSearchClient:function(t){return t.addAlgoliaAgent("docsearch.js","3.6.1"),e.transformSearchClient?e.transformSearchClient(t):t}})),function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:window;return"string"==typeof e?t.document.querySelector(e):e}(e.container,e.environment))}})); //# sourceMappingURL=index.js.map diff --git a/website/assets/images/articles/debunk-the-cross-platform-myth-600x521@2x.png b/website/assets/images/articles/debunk-the-cross-platform-myth-600x521@2x.png new file mode 100644 index 0000000000..732c38a754 Binary files /dev/null and b/website/assets/images/articles/debunk-the-cross-platform-myth-600x521@2x.png differ diff --git a/website/assets/images/articles/deploy-security-agents-1600x900@2x.png b/website/assets/images/articles/deploy-security-agents-1600x900@2x.png new file mode 100644 index 0000000000..60d79cac6c Binary files /dev/null and b/website/assets/images/articles/deploy-security-agents-1600x900@2x.png differ diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png new file mode 100644 index 0000000000..6ff5d63033 Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png differ diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg new file mode 100644 index 0000000000..e86a679ec5 Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg differ diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png b/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png new file mode 100644 index 0000000000..bb0a530d3e Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png differ diff --git a/website/assets/images/articles/fleet-4.55.0-1600x900@2x.png b/website/assets/images/articles/fleet-4.55.0-1600x900@2x.png new file mode 100644 index 0000000000..6d5b25e8f6 Binary files /dev/null and b/website/assets/images/articles/fleet-4.55.0-1600x900@2x.png differ diff --git a/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png b/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png new file mode 100644 index 0000000000..697877bc6c Binary files /dev/null and b/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png differ diff --git a/website/assets/images/articles/install-vpp-apps-on-macos-using-fleet-1600x900@2x.png b/website/assets/images/articles/install-vpp-apps-on-macos-using-fleet-1600x900@2x.png new file mode 100644 index 0000000000..514b510e0b Binary files /dev/null and b/website/assets/images/articles/install-vpp-apps-on-macos-using-fleet-1600x900@2x.png differ diff --git a/website/assets/images/articles/seamless-mdm-migration-1600x900@2x.png b/website/assets/images/articles/seamless-mdm-migration-1600x900@2x.png new file mode 100644 index 0000000000..abd44fa01f Binary files /dev/null and b/website/assets/images/articles/seamless-mdm-migration-1600x900@2x.png differ diff --git a/website/assets/images/articles/software-self-service-1600x900@2x.png b/website/assets/images/articles/software-self-service-1600x900@2x.png new file mode 100644 index 0000000000..60d79cac6c Binary files /dev/null and b/website/assets/images/articles/software-self-service-1600x900@2x.png differ diff --git a/website/assets/images/continue-thumbnail.png b/website/assets/images/continue-thumbnail.png deleted file mode 100644 index 4c3f8c39b8..0000000000 Binary files a/website/assets/images/continue-thumbnail.png and /dev/null differ diff --git a/website/assets/images/cropped-fleet-cloud-city-504x784@2x.png b/website/assets/images/cropped-fleet-cloud-city-504x784@2x.png new file mode 100644 index 0000000000..5f9c3c7aed Binary files /dev/null and b/website/assets/images/cropped-fleet-cloud-city-504x784@2x.png differ diff --git a/website/assets/images/cta-thumbnail-psystage-2-aware-128x128@2x.png b/website/assets/images/cta-thumbnail-psystage-2-aware-128x128@2x.png new file mode 100644 index 0000000000..abca22f145 Binary files /dev/null and b/website/assets/images/cta-thumbnail-psystage-2-aware-128x128@2x.png differ diff --git a/website/assets/images/cta-thumbnail-psystage-3-intrigued-128x128@2x.png b/website/assets/images/cta-thumbnail-psystage-3-intrigued-128x128@2x.png new file mode 100644 index 0000000000..488291df40 Binary files /dev/null and b/website/assets/images/cta-thumbnail-psystage-3-intrigued-128x128@2x.png differ diff --git a/website/assets/images/cta-thumbnail-psystage-4-has-use-case-128x128@2x.png b/website/assets/images/cta-thumbnail-psystage-4-has-use-case-128x128@2x.png new file mode 100644 index 0000000000..c00066c408 Binary files /dev/null and b/website/assets/images/cta-thumbnail-psystage-4-has-use-case-128x128@2x.png differ diff --git a/website/assets/images/cta-thumbnail-psystage-5-100x100@2x.png b/website/assets/images/cta-thumbnail-psystage-5-100x100@2x.png new file mode 100644 index 0000000000..3fa69407ac Binary files /dev/null and b/website/assets/images/cta-thumbnail-psystage-5-100x100@2x.png differ diff --git a/website/assets/images/fleet-profile-image.png b/website/assets/images/fleet-profile-image.png new file mode 100644 index 0000000000..3d28053398 Binary files /dev/null and b/website/assets/images/fleet-profile-image.png differ diff --git a/website/assets/images/icon-chevron-fleet-black-75-12x12@2x.png b/website/assets/images/icon-chevron-fleet-black-75-12x12@2x.png new file mode 100644 index 0000000000..c64bba4ca8 Binary files /dev/null and b/website/assets/images/icon-chevron-fleet-black-75-12x12@2x.png differ diff --git a/website/assets/images/icon-copy-16x16@2x.png b/website/assets/images/icon-copy-16x16@2x.png new file mode 100644 index 0000000000..7c2c83b4e8 Binary files /dev/null and b/website/assets/images/icon-copy-16x16@2x.png differ diff --git a/website/assets/images/icon-copy-clicked-checkmark-32x32@2x.png b/website/assets/images/icon-copy-clicked-checkmark-32x32@2x.png new file mode 100644 index 0000000000..faebb902aa Binary files /dev/null and b/website/assets/images/icon-copy-clicked-checkmark-32x32@2x.png differ diff --git a/website/assets/images/icon-form-success-12x12@2x.png b/website/assets/images/icon-form-success-12x12@2x.png deleted file mode 100644 index d09d7c9214..0000000000 Binary files a/website/assets/images/icon-form-success-12x12@2x.png and /dev/null differ diff --git a/website/assets/images/icon-form-success-16x16@2x.png b/website/assets/images/icon-form-success-16x16@2x.png new file mode 100644 index 0000000000..dd0d96c99b Binary files /dev/null and b/website/assets/images/icon-form-success-16x16@2x.png differ diff --git a/website/assets/images/mdm-debunk-cross-platform-myth-528x377@2x.png b/website/assets/images/mdm-debunk-cross-platform-myth-528x377@2x.png new file mode 100644 index 0000000000..62e14187e2 Binary files /dev/null and b/website/assets/images/mdm-debunk-cross-platform-myth-528x377@2x.png differ diff --git a/website/assets/images/permanent/mdm-ade-migration-1024x500.png b/website/assets/images/permanent/mdm-ade-migration-1024x500.png new file mode 100644 index 0000000000..461d1de9b6 Binary files /dev/null and b/website/assets/images/permanent/mdm-ade-migration-1024x500.png differ diff --git a/website/assets/images/permanent/mdm-manual-migration-1024x500.png b/website/assets/images/permanent/mdm-manual-migration-1024x500.png new file mode 100644 index 0000000000..f7700d4bde Binary files /dev/null and b/website/assets/images/permanent/mdm-manual-migration-1024x500.png differ diff --git a/website/assets/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png b/website/assets/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png new file mode 100644 index 0000000000..993bcbd72e Binary files /dev/null and b/website/assets/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png differ diff --git a/website/assets/images/psystage-1-unaware-558x680@2x.png b/website/assets/images/psystage-1-unaware-558x680@2x.png new file mode 100644 index 0000000000..af3cf08fcc Binary files /dev/null and b/website/assets/images/psystage-1-unaware-558x680@2x.png differ diff --git a/website/assets/images/psystage-2-aware-508x784@2x.png b/website/assets/images/psystage-2-aware-508x784@2x.png new file mode 100644 index 0000000000..2f3d6b4b00 Binary files /dev/null and b/website/assets/images/psystage-2-aware-508x784@2x.png differ diff --git a/website/assets/images/psystage-3-intrigued-558x680@2x.png b/website/assets/images/psystage-3-intrigued-558x680@2x.png new file mode 100644 index 0000000000..c4759ba068 Binary files /dev/null and b/website/assets/images/psystage-3-intrigued-558x680@2x.png differ diff --git a/website/assets/images/psystage-4-has-use-case-508x784@2x.png b/website/assets/images/psystage-4-has-use-case-508x784@2x.png new file mode 100644 index 0000000000..74d52db71b Binary files /dev/null and b/website/assets/images/psystage-4-has-use-case-508x784@2x.png differ diff --git a/website/assets/images/psystage-6-has-team-buy-in-504x784@2x.png b/website/assets/images/psystage-6-has-team-buy-in-504x784@2x.png new file mode 100644 index 0000000000..f601641ec4 Binary files /dev/null and b/website/assets/images/psystage-6-has-team-buy-in-504x784@2x.png differ diff --git a/website/assets/js/pages/articles/articles.page.js b/website/assets/js/pages/articles/articles.page.js index b4034386e9..8aa5dc2600 100644 --- a/website/assets/js/pages/articles/articles.page.js +++ b/website/assets/js/pages/articles/articles.page.js @@ -62,7 +62,22 @@ parasails.registerPage('articles', { }, mounted: async function() { - //… + if(this.category === 'guides') { + if(this.algoliaPublicKey) {// Note: Docsearch will only be enabled if sails.config.custom.algoliaPublicKey is set. If the value is undefined, the handbook search will be disabled. + docsearch({ + appId: 'NZXAYZXDGH', + apiKey: this.algoliaPublicKey, + indexName: 'fleetdm', + container: '#docsearch-query', + placeholder: 'Search', + debug: false, + clickAnalytics: true, + searchParameters: { + facetFilters: ['section:guides'] + }, + }); + } + } }, // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ diff --git a/website/assets/js/pages/articles/basic-article.page.js b/website/assets/js/pages/articles/basic-article.page.js index 29112e45ac..8a15643666 100644 --- a/website/assets/js/pages/articles/basic-article.page.js +++ b/website/assets/js/pages/articles/basic-article.page.js @@ -25,6 +25,20 @@ parasails.registerPage('basic-article', { let startValue = parseInt(ol.getAttribute('start'), 10) - 1; ol.style.counterReset = 'custom-counter ' + startValue; }); + if(this.algoliaPublicKey) {// Note: Docsearch will only be enabled if sails.config.custom.algoliaPublicKey is set. If the value is undefined, the handbook search will be disabled. + docsearch({ + appId: 'NZXAYZXDGH', + apiKey: this.algoliaPublicKey, + indexName: 'fleetdm', + container: '#docsearch-query', + placeholder: 'Search', + debug: false, + clickAnalytics: true, + searchParameters: { + facetFilters: ['section:docs'] + }, + }); + } }, // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ diff --git a/website/assets/js/pages/contact.page.js b/website/assets/js/pages/contact.page.js index 36571b672a..c7595825ca 100644 --- a/website/assets/js/pages/contact.page.js +++ b/website/assets/js/pages/contact.page.js @@ -50,8 +50,8 @@ parasails.registerPage('contact', { if(this.formToShow === 'contact'){ this.formToDisplay = this.formToShow; } else if(!this.primaryBuyingSituation){ - // Default to contact form for users who have no primaryBuyingSituation set. - this.formToDisplay = 'contact'; + // Otherwise, default to the formToShow value from the page's controller. + this.formToDisplay = this.formToShow; } if(this.primaryBuyingSituation){ // If the user has a priamry buying situation set in their sesssion, pre-fill the form. // Note: this will be overriden if the user is logged in and has a primaryBuyingSituation set in the database. diff --git a/website/assets/js/pages/docs/basic-documentation.page.js b/website/assets/js/pages/docs/basic-documentation.page.js index d09d42f605..1695eb613d 100644 --- a/website/assets/js/pages/docs/basic-documentation.page.js +++ b/website/assets/js/pages/docs/basic-documentation.page.js @@ -43,7 +43,7 @@ parasails.registerPage('basic-documentation', { return _.startsWith(page.url, '/docs'); }); this.pagesBySectionSlug = (() => { - const DOCS_SLUGS = ['get-started', 'deploy', 'using-fleet', 'configuration', 'rest-api']; + const DOCS_SLUGS = ['get-started', 'deploy', 'configuration', 'rest-api']; let sectionSlugs = _.uniq(this.pages.map((page) => page.url.split(/\//).slice(-2)[0])); let pagesBySectionSlug = {}; @@ -129,13 +129,11 @@ parasails.registerPage('basic-documentation', { // console.log(subtopics); this.subtopics = (() => { - let subtopics = $('#body-content').find('h2.markdown-heading').map((_, el) => el.innerText); - subtopics = $.makeArray(subtopics).map((title) => { - // Removing all apostrophes from the title to keep _.kebabCase() from turning words like 'user’s' into 'user-s' - let kebabCaseFriendlyTitle = title.replace(/[\’\']/g, ''); + let subtopics = $('#body-content').find('h2.markdown-heading').map((_, el) => el); + subtopics = $.makeArray(subtopics).map((subheading) => { return { - title, - url: '#' + _.kebabCase(kebabCaseFriendlyTitle.toLowerCase()), + title: subheading.innerText, + url: $(subheading).find('a.markdown-link').attr('href'), }; }); return subtopics; @@ -258,22 +256,23 @@ parasails.registerPage('basic-documentation', { return this.pagesBySectionSlug[slug]; }, - findAndSortNavSectionsByUrl: function (url='') { - let NAV_SECTION_ORDER_BY_DOCS_SLUG = { - 'using-fleet':['The basics', 'Device management', 'Vuln management', 'Security compliance', 'Osquery management', 'Dig deeper'], - }; - let slug = _.last(url.split(/\//)); - // - if(NAV_SECTION_ORDER_BY_DOCS_SLUG[slug]) { - let orderForThisSection = NAV_SECTION_ORDER_BY_DOCS_SLUG[slug]; - let sortedSection = {}; - orderForThisSection.map((section)=>{ - sortedSection[section] = this.navSectionsByDocsSectionSlug[slug][section]; - }); - this.navSectionsByDocsSectionSlug[slug] = sortedSection; - } - return this.navSectionsByDocsSectionSlug[slug]; - }, + // FUTURE: remove this function if we do not add subsections to docs sections. + // findAndSortNavSectionsByUrl: function (url='') { + // let NAV_SECTION_ORDER_BY_DOCS_SLUG = { + // 'using-fleet':['The basics', 'Device management', 'Vuln management', 'Security compliance', 'Osquery management', 'Dig deeper'], + // }; + // let slug = _.last(url.split(/\//)); + // // + // if(NAV_SECTION_ORDER_BY_DOCS_SLUG[slug]) { + // let orderForThisSection = NAV_SECTION_ORDER_BY_DOCS_SLUG[slug]; + // let sortedSection = {}; + // orderForThisSection.map((section)=>{ + // sortedSection[section] = this.navSectionsByDocsSectionSlug[slug][section]; + // }); + // this.navSectionsByDocsSectionSlug[slug] = sortedSection; + // } + // return this.navSectionsByDocsSectionSlug[slug]; + // }, getActiveSubtopicClass: function (currentLocation, url) { return _.last(currentLocation.split(/#/)) === _.last(url.split(/#/)) ? 'active' : ''; diff --git a/website/assets/js/pages/fleetctl-preview.page.js b/website/assets/js/pages/fleetctl-preview.page.js index 48dd281a4b..33938abf58 100644 --- a/website/assets/js/pages/fleetctl-preview.page.js +++ b/website/assets/js/pages/fleetctl-preview.page.js @@ -3,7 +3,20 @@ parasails.registerPage('fleetctl-preview', { // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ data: { - selectedPlatform: 'macos' + selectedPlatform: 'macos', + installCommands: { + macos: 'curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash', + linux: 'curl -sSL https://fleetdm.com/resources/install-fleetctl.sh | bash', + windows: `for /f "tokens=1,* delims=:" %a in ('curl -s https://api.github.com/repos/fleetdm/fleet/releases/latest ^| findstr "browser_download_url" ^| findstr "_windows.zip"') do (curl -kOL %b) && if not exist "%USERPROFILE%\\.fleetctl" mkdir "%USERPROFILE%\\.fleetctl" && for /f "delims=" %a in ('dir /b fleetctl_*_windows.zip') do tar -xf "%a" --strip-components=1 -C "%USERPROFILE%\\.fleetctl" && del "%a"`, + npm: 'npm install fleetctl -g', + }, + fleetctlPreviewTerminalCommand: { + macos: '~/.fleetctl/fleetctl preview', + linux: '~/.fleetctl/fleetctl preview', + windows: `%USERPROFILE%\\.fleetctl\\fleetctl preview`, + npm: 'fleetctl preview', + } + }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ @@ -20,6 +33,27 @@ parasails.registerPage('fleetctl-preview', { // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { - //… + clickCopyInstallCommand: async function(platform) { + let commandToInstallFleetctl = this.installCommands[platform]; + // https://caniuse.com/mdn-api_clipboard_writetext + $('[purpose="install-copy-button"]').addClass('copied'); + await setTimeout(()=>{ + $('[purpose="install-copy-button"]').removeClass('copied'); + }, 2000); + navigator.clipboard.writeText(commandToInstallFleetctl); + }, + + clickCopyTerminalCommand: async function(platform) { + let commandToRunFleetPreview = this.fleetctlPreviewTerminalCommand[platform]; + if(this.trialLicenseKey && !this.userHasExpiredTrialLicense){ + commandToRunFleetPreview += ' --license-key '+this.trialLicenseKey; + } + $('[purpose="command-copy-button"]').addClass('copied'); + await setTimeout(()=>{ + $('[purpose="command-copy-button"]').removeClass('copied'); + }, 2000); + // https://caniuse.com/mdn-api_clipboard_writetext + navigator.clipboard.writeText(commandToRunFleetPreview); + }, } }); diff --git a/website/assets/js/pages/homepage.page.js b/website/assets/js/pages/homepage.page.js index 5a5100cebc..b9bfc560b4 100644 --- a/website/assets/js/pages/homepage.page.js +++ b/website/assets/js/pages/homepage.page.js @@ -12,6 +12,10 @@ parasails.registerPage('homepage', { // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { //… + if(window.location.hash === '#unsubscribed'){ + this.modal = 'unsubscribed'; + window.location.hash = ''; + } }, mounted: async function() { //… diff --git a/website/assets/js/pages/pricing.page.js b/website/assets/js/pages/pricing.page.js index 69b6ba3e74..37a7118abc 100644 --- a/website/assets/js/pages/pricing.page.js +++ b/website/assets/js/pages/pricing.page.js @@ -6,6 +6,7 @@ parasails.registerPage('pricing', { pricingMode: 'all', modal: '', selectedFeature: undefined, + showExpandedTable: false, }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ diff --git a/website/assets/js/pages/start.page.js b/website/assets/js/pages/start.page.js index e6f9514e05..3b95e7218b 100644 --- a/website/assets/js/pages/start.page.js +++ b/website/assets/js/pages/start.page.js @@ -18,6 +18,7 @@ parasails.registerPage('start', { 'what-does-your-team-manage-eo-it': {}, 'what-does-your-team-manage-vm': {}, 'what-do-you-manage-mdm': {}, + 'cross-platform-mdm': {stepCompleted: true}, 'is-it-any-good': {stepCompleted: true}, 'what-did-you-think': {}, 'deploy-fleet-in-your-environment': {stepCompleted: true}, @@ -25,6 +26,8 @@ parasails.registerPage('start', { 'how-was-your-deployment': {}, 'whats-left-to-get-you-set-up': {}, }, + + psychologicalStage: '2 - Aware', // For tracking client-side validation errors in our form. // > Has property set to `true` for each invalid property in `formData`. formErrors: { /* … */ }, @@ -73,6 +76,7 @@ parasails.registerPage('start', { // Success state when form has been submitted cloudSuccess: false, + primaryBuyingSituation: undefined, }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ @@ -89,6 +93,9 @@ parasails.registerPage('start', { this.formData['what-are-you-using-fleet-for'] = {primaryBuyingSituation: this.primaryBuyingSituation}; } } + if(this.me.psychologicalStage){ + this.psychologicalStage = this.me.psychologicalStage; + } if(window.location.hash) { if(typeof analytics !== 'undefined') { if(window.location.hash === '#signup') { @@ -126,11 +133,24 @@ parasails.registerPage('start', { handleSubmittingForm: async function(argins) { let formDataForThisStep = _.clone(argins); let nextStep = this.getNextStep(); - let getStartedProgress = await Cloud.saveQuestionnaireProgress.with({ + let questionanireProgress = await Cloud.saveQuestionnaireProgress.with({ currentStep: this.currentStep, formData: formDataForThisStep, }); - this.previouslyAnsweredQuestions[this.currentStep] = getStartedProgress[this.currentStep]; + + this.previouslyAnsweredQuestions[this.currentStep] = questionanireProgress.getStartedProgress[this.currentStep]; + this.psychologicalStage = questionanireProgress.psychologicalStage; + this.primaryBuyingSituation = questionanireProgress.primaryBuyingSituation; + if(typeof analytics !== 'undefined') { + analytics.identify(this.me.id, { + email: this.me.emailAddress, + firstName: this.me.firstName, + lastName: this.me.lastName, + company: this.me.organization, + primaryBuyingSituation: this.primaryBuyingSituation, + psychologicalStage: this.psychologicalStage, + }); + } if(_.startsWith(nextStep, '/')){ this.goto(nextStep); } else { @@ -140,6 +160,9 @@ parasails.registerPage('start', { }, clickGoToPreviousStep: async function() { switch(this.currentStep) { + case 'what-are-you-using-fleet-for': + this.currentStep = 'start'; + break; case 'have-you-ever-used-fleet': this.currentStep = 'what-are-you-using-fleet-for'; break; @@ -175,9 +198,12 @@ parasails.registerPage('start', { } else if(primaryBuyingSituation === 'vm') { this.currentStep = 'what-does-your-team-manage-vm'; } else if(primaryBuyingSituation === 'mdm') { - this.currentStep = 'what-do-you-manage-mdm'; + this.currentStep = 'cross-platform-mdm'; } break; + case 'cross-platform-mdm': + this.currentStep = 'what-do-you-manage-mdm'; + break; case 'lets-talk-to-your-team': this.currentStep = 'how-many-hosts'; break; @@ -274,6 +300,9 @@ parasails.registerPage('start', { nextStepInForm = 'is-it-any-good'; break; case 'what-do-you-manage-mdm': + nextStepInForm = 'cross-platform-mdm'; + break; + case 'cross-platform-mdm': nextStepInForm = 'is-it-any-good'; break; case 'is-it-any-good': @@ -300,7 +329,7 @@ parasails.registerPage('start', { } else if(this.formData['how-was-your-deployment'].howWasYourDeployment === 'kinda-stuck'){ nextStepInForm = '/contact'; } else if(this.formData['how-was-your-deployment'].howWasYourDeployment === 'havent-gotten-to-it') { - nextStepInForm = 'deploy-fleet-in-your-environment'; + nextStepInForm = '/contact'; } else if(this.formData['how-was-your-deployment'].howWasYourDeployment === 'changed-mind-want-managed-deployment'){ nextStepInForm = 'how-many-hosts'; } else if(this.formData['how-was-your-deployment'].howWasYourDeployment === 'decided-to-not-use-fleet'){ diff --git a/website/assets/resources/install-fleetctl.sh b/website/assets/resources/install-fleetctl.sh index 4c21a19b11..5f5d4a2774 100644 --- a/website/assets/resources/install-fleetctl.sh +++ b/website/assets/resources/install-fleetctl.sh @@ -28,8 +28,8 @@ version_gt() { OS="$(uname -s)" case "${OS}" in - Linux*) OS='linux';; - Darwin*) OS='macos';; + Linux*) OS='linux' OS_DISPLAY_NAME='Linux';; + Darwin*) OS='macos' OS_DISPLAY_NAME='macOS';; *) echo "Unsupported operating system: ${OS}"; exit 1;; esac @@ -41,14 +41,14 @@ mkdir -p "${FLEETCTL_INSTALL_DIR}" 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}..." +echo "Downloading fleetctl ${latest_strippedVersion} for ${OS_DISPLAY_NAME}..." 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" +echo "2. To access your Fleet Premium trial, head to fleetdm.com/try-fleet and run the command in step 2." # Verify if the binary is executable if [[ ! -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]]; then diff --git a/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md b/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md index f3fad15238..f1fbefd163 100644 --- a/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md +++ b/website/assets/resources/security-awareness/2022-05-security-awareness-slides.md @@ -132,7 +132,7 @@ BEC leverages our willingness to help people. ## Money transfers -We have a strict process related to payments and wire transfers. If you are in the BizOps team, make sure you are aware of it. +We have a strict process related to payments and wire transfers. If you are in the Digital Experience team, make sure you are aware of it. ## Working from shady networks and cool locations @@ -179,7 +179,7 @@ Undoing git history is complicated. Consider this secret forever leaked. 1. Don't panic. It's encrypted. 2. Post about it in #g-security. -3. In the thread in #g-security, inform someone from the BizOps team. They'll help you get a new one ASAP! +3. In the thread in #g-security, inform someone from the Digital Experience team. They'll help you get a new one ASAP! ## If... you lose your Yubikey(s) diff --git a/website/assets/styles/docsearch.less b/website/assets/styles/docsearch.less index 9e831cbe1b..111bd4b235 100644 --- a/website/assets/styles/docsearch.less +++ b/website/assets/styles/docsearch.less @@ -149,6 +149,10 @@ justify-content: center; } +.DocSearch-VisuallyHiddenForAccessibility { + display: none; +} + .DocSearch-Container--Stalled .DocSearch-MagnifierLabel { display: none; } diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less index d1be7f4a0a..6eefec24a9 100644 --- a/website/assets/styles/layout.less +++ b/website/assets/styles/layout.less @@ -144,8 +144,27 @@ html, body { [purpose='banner-image'] { height: 80px; min-width: 80px; - background-image: url('/images/continue-thumbnail.png'); background-size: cover; + background-position: center; + border-radius: 12px; + &.stage-one { + background-image: url('/images/psystage-1-unaware-558x680@2x.png'); + } + &.stage-two { + background-image: url('/images/cta-thumbnail-psystage-2-aware-128x128@2x.png'); + } + &.stage-three { + background-image: url('/images/cta-thumbnail-psystage-3-intrigued-128x128@2x.png'); + } + &.stage-four { + background-image: url('/images/cta-thumbnail-psystage-4-has-use-case-128x128@2x.png'); + } + &.stage-five { + background-image: url('/images/cta-thumbnail-psystage-5-100x100@2x.png'); + } + &.stage-six { + background-image: url('/images/psystage-6-has-team-buy-in-504x784@2x.png'); + } } } [purpose='banner-body'].collapsed { @@ -425,8 +444,9 @@ html, body { } } [purpose='gh-button'] { - margin-left: 20px; - margin-right: 20px; + padding: 0px 20px; + min-width: 140px; + width: 140px; } [purpose='header-dropdown'] { box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.4); @@ -656,7 +676,6 @@ body.detected-mobile { [purpose='banner-image'] { height: 54px; min-width: 54px; - background-image: url('/images/continue-thumbnail.png'); background-size: cover; } } @@ -667,7 +686,6 @@ body.detected-mobile { [purpose='banner-image'] { height: 54px; min-width: 54px; - background-image: url('/images/continue-thumbnail.png'); background-size: cover; } transform: none; diff --git a/website/assets/styles/pages/articles/articles.less b/website/assets/styles/pages/articles/articles.less index 552b110da0..770943f00c 100644 --- a/website/assets/styles/pages/articles/articles.less +++ b/website/assets/styles/pages/articles/articles.less @@ -1,6 +1,7 @@ #articles { - padding-left: 24px; - padding-right: 24px; + [purpose='page-container'] { + padding: 64px; + } [purpose='categories-and-search'] { padding-top: 40px; @@ -30,9 +31,15 @@ } } [purpose='category-title'] { - padding-top: 80px; padding-bottom: 40px; } + + [purpose='guides-category-page'] { + [purpose='category-title'] { + margin-left: 12px; + margin-right: 6px; + } + } [purpose='rss-button'] { padding: 4px 8px; cursor: pointer; @@ -49,7 +56,6 @@ font-size: 16px; } } - [purpose='rss-button'].copied::after { content: 'Link copied'; display: flex; @@ -74,11 +80,100 @@ 100% {opacity: 0;} } + [purpose='search'] { + // Note: We're using classes here to override the default Docsearch styles; + button { + width: 100%; + cursor: text; + margin: 0; + } + .DocSearch-Button { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background-color: #FFF; + padding: 6px; + height: 36px; + margin: 0; + width: 256px; + } + .DocSearch-Button:hover { + box-shadow: none; + border: 1px solid @core-fleet-black-25; + color: @core-fleet-black-50; + } + .DocSearch-Search-Icon { + margin-left: 10px; + height: 16px; + width: 16px; + color: @core-fleet-black-50; + stroke-width: 3px; + } + .DocSearch-Button-Keys { + display: none; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .DocSearch-Button-Placeholder { + font-size: 16px; + font-weight: 400; + padding-left: 12px; + } + [purpose='disabled-search'] { + input { + padding-top: 6px; + padding-bottom: 6px; + border: none; + } &::placeholder { + font-size: 16px; + line-height: 24px; + } + .input-group { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background: #FFF; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .docsearch-input { + height: 100%; + width: 98%; + } + .docsearch-input:focus-visible { + outline: none; + } + .ds-input:focus { + outline: rgba(0, 0, 0, 0); + } + .input-group-text { + color: @core-fleet-black-50; + } + .form-control { + height: 36px; + padding: 0px; + font-size: 16px; + } &:focus { + border: none; + } + } + } + [purpose='articles'] { - padding-bottom: 80px; + width: 100%; + max-width: 100%; [purpose='article-card'] { - + a { + text-decoration: none; + color: @core-fleet-black-75; + } margin-left: 10px; margin-right: 10px; margin-bottom: 40px; @@ -123,6 +218,71 @@ } } } + [purpose='guides'] { + column-count: 3; + margin-left: -2px; + margin-right: 14px; + } + [purpose='guide-card'] { + &:hover { + box-shadow: 0px 4px 16px 0px #E2E4EA; + } + max-width: 343px; + margin-left: 12px; + margin-right: 12px; + margin-bottom: 24px; + border-radius: 16px; + box-shadow: none; + border: 1px solid #E2E4EA; + display: inline-block; + img { + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + a { + text-decoration: none; + color: @core-fleet-black-75; + } + [purpose='article-card-body'] { + padding: 32px; + [purpose='category-name'] { + text-transform: uppercase; + color: @core-fleet-black-50; + font-size: 12px; + font-weight: 700; + line-height: 20px; + text-decoration: none; + } + p { + color: #515774; + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + [purpose='article-title'] { + + display: block; + text-decoration: none; + color: @core-fleet-black; + margin-bottom: 16px; + h5 { + font-weight: 800; + font-size: 16px; + line-height: 120%; + } + } + [purpose='article-details'] { + font-size: 12px; + line-height: 28px; + span { + color: @core-fleet-black-50; + } + p { + margin-bottom: 0px; + } + } + } + } @media (min-width: 1200px) { @@ -131,8 +291,6 @@ margin-right: 10px; } [purpose='articles'] { - margin-left: -25px; - margin-right: -25px; [purpose='article-card'] { flex: 1 1 350px; margin-left: 20px; @@ -155,8 +313,12 @@ @media (max-width: 991px) { - padding-left: 40px; - padding-right: 40px; + [purpose='page-container'] { + padding: 64px 32px; + } + [purpose='guide-card'] { + max-width: unset; + } [purpose='categories-and-search'] { padding-top: 40px; [purpose='categories'] { @@ -171,9 +333,6 @@ } } } - [purpose='category-title'] { - padding-top: 60px; - } [purpose='articles'] { [purpose='article-card'] { flex: 1 1 330px; @@ -182,27 +341,49 @@ margin-left: -10px; margin-right: -10px; } + [purpose='search'] { + .DocSearch-Button { + margin-left: 24px; + width: 200px; + } + } } @media (max-width: 767px) { + [purpose='categories-and-search'] { [purpose='search'] { width: 100%; } } - + [purpose='guides'] { + column-count: 2; + } [purpose='articles'] { + margin: 0 auto; + column-count: 2; [purpose='article-card'] { max-width: 100%; } } + [purpose='search'] { + .DocSearch-Button { + margin-top: 0px; + margin-left: 0px; + width: 100%; + } + } } @media (max-width: 576px) { - padding-left: 24px; - padding-right: 24px; + [purpose='page-container'] { + padding: 32px 24px; + } + [purpose='guides'] { + column-count: 1; + } [purpose='articles'] { margin-left: -10px; margin-right: -10px; @@ -211,5 +392,15 @@ max-width: 100%; } } + [purpose='guide-card'] { + width: 100%; + } + [purpose='search'] { + .DocSearch-Button { + margin-top: 0px; + margin-left: 0px; + width: 100%; + } + } } } diff --git a/website/assets/styles/pages/articles/basic-article.less b/website/assets/styles/pages/articles/basic-article.less index 034b5376a6..ad70251560 100644 --- a/website/assets/styles/pages/articles/basic-article.less +++ b/website/assets/styles/pages/articles/basic-article.less @@ -10,8 +10,126 @@ width: 100%; } + [purpose='breadcrumbs-and-search'] { + padding-top: 64px; + max-width: 1072px; + margin: auto; + font-size: 14px; + [purpose='breadcrumbs'] { + margin-right: 24px; + } + [purpose='search'] { + // Note: We're using classes here to override the default Docsearch styles; + button { + width: 100%; + cursor: text; + margin: 0; + } + .DocSearch-Button { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background-color: #FFF; + padding: 6px; + height: 36px; + margin: 0; + width: 256px; + } + .DocSearch-Button:hover { + box-shadow: none; + border: 1px solid @core-fleet-black-25; + color: @core-fleet-black-50; + } + .DocSearch-Search-Icon { + margin-left: 10px; + height: 16px; + width: 16px; + color: @core-fleet-black-50; + stroke-width: 3px; + } + .DocSearch-Button-Keys { + display: none; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .DocSearch-Button-Placeholder { + font-size: 16px; + font-weight: 400; + padding-left: 12px; + } + [purpose='disabled-search'] { + input { + padding-top: 6px; + padding-bottom: 6px; + border: none; + } &::placeholder { + font-size: 16px; + line-height: 24px; + color: #8B8FA2; + } + .input-group { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background: #FFF; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .form-control { + border-radius: 6px; + padding: 6px; + height: 36px; + margin: 0; + width: 212px; + } + .docsearch-input:focus-visible { + outline: none; + } + .ds-input:focus { + outline: rgba(0, 0, 0, 0); + } + .input-group-text { + color: @core-fleet-black-50; + } + .form-control { + height: 36px; + padding: 0px; + font-size: 16px; + } &:focus { + border: none; + } + } + } + + [purpose='breadcrumbs-category'] { + color: #8B8FA2; + text-transform: capitalize; + margin-right: 8px; + &:hover { + color: #192147; + text-decoration: none; + } + } + [purpose='breadcrumbs-title'] { + margin-left: 8px; + } + + } + [purpose='article-container'] { + max-width: 800px; + margin: auto; + display: flex; + flex-direction: column; + + } [purpose='article-title'] { - padding-top: 80px; + padding-top: 64px; margin-bottom: 28px; h1 { margin-bottom: 4px; @@ -107,9 +225,9 @@ } } [purpose='article-content'] { - padding-top: 24px; - padding-bottom: 24px; - word-break: break-word; + padding-top: 40px; + padding-bottom: 40px; + word-wrap: break-word; h1:first-of-type { display: none; } @@ -126,8 +244,13 @@ padding: 24px 0px 16px 0px; } a { - color: @core-vibrant-blue; + color: @core-fleet-black-75; word-break: break-word; + text-decoration: underline; + text-underline-offset: 2px; + &:hover { + color: @core-fleet-black-75; + } } h2 { font-size: 24px; @@ -429,6 +552,18 @@ [purpose='article-content'] { padding-bottom: 0px; } + [purpose='breadcrumbs-and-search'] { + [purpose='breadcrumbs'] { + margin-bottom: 24px; + margin-right: auto; + } + [purpose='search'] { + width: 100%; + .DocSearch-Button { + width: 100%; + } + } + } } @media (max-width: 769px) { @@ -449,6 +584,10 @@ margin-bottom: 16px; } } + [purpose='breadcrumbs-and-search'] { + padding-top: 32px; + + } } @media (max-width: 576px) { diff --git a/website/assets/styles/pages/device-management.less b/website/assets/styles/pages/device-management.less index e0669aa959..10557dc619 100644 --- a/website/assets/styles/pages/device-management.less +++ b/website/assets/styles/pages/device-management.less @@ -35,6 +35,7 @@ line-height: @text-lineheight; color: @core-fleet-black-75; } + strong { color: @core-fleet-black; } @@ -393,10 +394,17 @@ [purpose='feature'] { - margin-bottom: 140px; + margin-bottom: 100px; h3 { margin-bottom: 24px; } + a { + color: @core-fleet-black-75; + text-decoration: underline; + &:hover { + color: @core-fleet-black-75; + } + } &:last-of-type { margin-bottom: 0px; } @@ -429,6 +437,7 @@ [purpose='feature-text'] { width: 468px; h2 { + font-size: 28px; margin-bottom: 32px; } p { @@ -487,13 +496,38 @@ } } } - [purpose='three-column-features'] { - margin-bottom: 160px; - max-width: 1080px; - [purpose='feature-row'] { - [purpose='feature-column'] { - max-width: 267px; + [purpose='responsive-feature-row'] { + margin-right: 0px; + margin-left: 0px; + [purpose='feature-item'] { + padding-right: 24px; + padding-left: 24px; + margin-bottom: 64px; + h5 { + font-size: 16px; + line-height: 1.2; + font-weight: 800; } + a { + color: @core-fleet-black-75; + text-decoration: underline; + &:hover { + color: @core-fleet-black-75; + } + } + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; + } + } + } + [purpose='three-column-features'] { + max-width: 1080px; + h2 { + font-size: 32px; + line-height: 48px; + margin-bottom: 40px; } } [purpose='button-row'] { @@ -661,6 +695,18 @@ width: 50%; } } + [purpose='responsive-feature-row'] { + [purpose='feature-item'] { + padding-right: 24px; + padding-left: 24px; + margin-bottom: 64px; + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; + } + } + } [purpose='testimonial-videos'] { width: 410px; @@ -737,6 +783,16 @@ } } } + [purpose='three-column-features'] { + [purpose='feature-row'] { + [purpose='feature-item'] { + margin-left: 32px; + margin-right: 32px; + max-width: 266px; + width: 33%; + } + } + } } @media (max-width: 767px) { @@ -747,6 +803,9 @@ font-size: 42px; } } + [purpose='feature'] { + margin-bottom: 80px; + } [purpose='calendar-feature'] { [purpose='feature-video'] { @@ -805,12 +864,16 @@ } [purpose='feature'].flex-column { [purpose='feature-text'] { - margin-left: auto; + width: 100%; + margin-left: 0px; + margin-right: 0px; } } [purpose='feature'].flex-column-reverse { [purpose='feature-text'] { - margin-right: auto; + width: 100%; + margin-left: 0px; + margin-right: 0px; } } [purpose='feature-image'] { @@ -844,6 +907,16 @@ height: 304px; } } + [purpose='three-column-features'] { + [purpose='feature-row'] { + [purpose='feature-item'] { + margin-left: 24px; + margin-right: 24px; + max-width: 266px; + width: 33%; + } + } + } } @media (max-width: 575px) { @@ -871,6 +944,19 @@ padding-left: 24px; } } + [purpose='responsive-feature-row'] { + [purpose='feature-item'] { + padding-right: 0px; + padding-left: 0px; + margin-bottom: 64px; + text-align: center; + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; + } + } + } [purpose='page-section'] { padding-top: 48px; padding-bottom: 48px; @@ -895,6 +981,21 @@ height: calc(~'9/16 * 95vw'); } } + [purpose='three-column-features'] { + [purpose='feature-row'] { + [purpose='feature-item'] { + + margin-left: 0; + margin-right: 0; + text-align: center; + max-width: 320px; + width: 100%; + &:not(:last-of-type) { + margin-bottom: 60px; + } + } + } + } [purpose='two-column-features'] { [purpose='feature-row'] { margin-bottom: 64px; diff --git a/website/assets/styles/pages/endpoint-ops.less b/website/assets/styles/pages/endpoint-ops.less index 52cdb20351..a2459aa9eb 100644 --- a/website/assets/styles/pages/endpoint-ops.less +++ b/website/assets/styles/pages/endpoint-ops.less @@ -361,44 +361,52 @@ line-height: 24px; } } - [purpose='three-column-features'] { - margin-bottom: 160px; max-width: 1080px; - [purpose='feature-row'] { - &:not(:last-of-type) { - margin-bottom: 60px; - } - [purpose='feature-item'] { - margin-left: 40px; - margin-right: 40px; - max-width: 266px; - width: 33%; - p { - font-size: 14px; - font-weight: 400; - line-height: 21px; - } - } - } + margin-bottom: 160px; h2 { font-size: 32px; line-height: 48px; margin-bottom: 40px; } } + [purpose='responsive-feature-row'] { + margin-right: 0px; + margin-left: 0px; + [purpose='feature-item'] { + padding-right: 24px; + padding-left: 24px; + margin-bottom: 64px; + h5 { + font-size: 16px; + line-height: 1.2; + font-weight: 800; + } + a { + color: @core-fleet-black-75; + text-decoration: underline; + &:hover { + color: @core-fleet-black-75; + } + } + img { + height: 48px; + width: auto; + margin-bottom: 16px; + } + h5 { + font-weight: 800; + font-size: 16px; + line-height: 1.2; + margin-bottom: 16px; + } - [purpose='feature-item'] { - img { - height: 48px; - width: auto; - margin-bottom: 16px; - } - h5 { - font-weight: 800; - font-size: 16px; - line-height: 27px; - margin-bottom: 16px; + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; + margin-bottom: 0px; + } } } @@ -541,6 +549,18 @@ [purpose='feature-text'] { width: 410px; } + [purpose='responsive-feature-row'] { + [purpose='feature-item'] { + padding-right: 24px; + padding-left: 24px; + margin-bottom: 64px; + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; + } + } + } } @media (max-width: 767px) { @@ -701,18 +721,16 @@ margin-left: 10px; } } - [purpose='three-column-features'] { - [purpose='feature-row'] { - [purpose='feature-item'] { - - margin-left: 0; - margin-right: 0; - text-align: center; - max-width: 320px; - width: 100%; - &:not(:last-of-type) { - margin-bottom: 60px; - } + [purpose='responsive-feature-row'] { + [purpose='feature-item'] { + padding-right: 0px; + padding-left: 0px; + margin-bottom: 64px; + text-align: center; + p { + font-size: 14px; + font-weight: 400; + line-height: 21px; } } } diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less index 42cadfb9be..46ffa53c30 100644 --- a/website/assets/styles/pages/entrance/login.less +++ b/website/assets/styles/pages/entrance/login.less @@ -1,45 +1,74 @@ #login { - - padding-top: 80px; - h1 { - font-size: 28px; - line-height: 38px; + font-size: 32px; + line-height: 120%; } a { + line-height: 150%; color: @core-fleet-black-75; text-decoration: underline; text-underline-offset: 2px; + } + p { line-height: 150%; } - [purpose='customer-login-container'] { - max-width: 560px; - } - [purpose='login-container'] { - max-width: 560px; - [purpose='customer-portal-form'] { - max-width: 560px; - } + [purpose='page-container'] { + padding: 64px 128px 64px 128px; + max-width: 1200px; } + [purpose='page-heading'] { - padding-left: 30px; - padding-right: 30px; - text-align: center; - margin-bottom: 40px; + text-align: left; + margin-bottom: 48px; } - [purpose='register-link'] { - margin-bottom: 8px; - a { - float: right; + + [purpose='quote-and-logos'] { + max-width: 310px; + } + [purpose='quote'] { + margin-top: 8px; + } + [purpose='quote-text'] { + font-size: 14px; + line-height: 150%; + font-style: italic; + } + [purpose='quote-author-info'] { + display: inline-flex; + padding: 4px 16px 4px 4px; + border-radius: 28px; + width: fit-content; + margin-top: 8px; + margin-bottom: 48px; + [purpose='job-title'] { color: @core-fleet-black-75; - text-decoration: underline; - font-size: 14px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + margin-bottom: 0px; } + [purpose='name'] { + font-size: 12px; + font-weight: 700; + line-height: 18px; + margin-bottom: 0px; + } + [purpose='profile-picture'] { + margin-right: 16px; + img { + height: 32px; + width: 32px; + } + } + } + [purpose='logos'] { + max-width: 310px; + } + [purpose='login-form'] { + width: 528px; } [purpose='customer-portal-form'] { - max-width: 560px; border-radius: 16px; - margin-bottom: 40px; padding: 20px 32px 32px 32px; label { color: @core-fleet-black; @@ -50,8 +79,36 @@ height: 40px; border-radius: 6px; } - .card-body { - padding: 2em; + .selectbox { + + position: relative; + } + .selectbox::after { + content: url('/images/chevron-12x8@2x.png'); + right: 14px; + transform: scale(0.5); + top: 14px; + position: absolute; + pointer-events: none; + } + .selectbox select { + border-radius: 6px; + height: 48px; + appearance: none; + -webkit-appearance: none; + } + .small { + font-size: 12px; + } + } + + [purpose='register-link'] { + margin-bottom: 4px; + a { + float: right; + color: @core-fleet-black-75; + text-decoration: underline; + font-size: 14px; } } @@ -74,23 +131,64 @@ font-weight: 700; } } + @media (max-width: 1200px) { + [purpose='page-container'] { + padding: 64px; + } + } + @media (max-width: 991px) { + [purpose='signup-form'] { + max-width: 528px; + } + [purpose='quote-and-logos'] { + max-width: 528px; + margin-top: 48px; + } + [purpose='logos'] { + max-width: 528px; + } + [purpose='page-heading'] { + text-align: center; + margin-left: auto; + margin-right: auto; + margin-bottom: 48px; + max-width: 528px; + } + [purpose='page-container'] { + padding: 64px 32px; + } + + } @media (max-width: 768px) { - padding-top: 60px; [purpose='customer-portal-form'] { max-width: unset; } + [purpose='signup-form'] { + width: 100%; + } + [purpose='quote-and-logos'] { + width: 100%; + } + [purpose='logos'] { + width: 100%; + } } @media (max-width: 576px) { - padding-top: 40px; [purpose='page-heading'] { padding-left: 0px; padding-right: 0px; } + [purpose='page-container'] { + padding: 48px 24px; + } + [purpose='login-link'] { + margin-bottom: 12px; + } [purpose='customer-portal-form'] { .card-body { - padding: 1em; + padding: 1.5em 1em; } } } diff --git a/website/assets/styles/pages/fleetctl-preview.less b/website/assets/styles/pages/fleetctl-preview.less index 443db87c12..60b055eed9 100644 --- a/website/assets/styles/pages/fleetctl-preview.less +++ b/website/assets/styles/pages/fleetctl-preview.less @@ -104,18 +104,27 @@ } } [purpose='terminal-commands'] { - padding: 16px 24px; + padding: 16px 60px 16px 24px; border: 1px solid @core-fleet-black-25; border-radius: 4px; margin: 16px 0px 0px; background: @ui-off-white; width: 100%; - overflow-x: scroll; + overflow: auto; scrollbar-width: none; + position: relative; &::-webkit-scrollbar { display: none; } - p { + [purpose='command-container'] { + overflow-x: scroll; + scrollbar-width: none; + position: relative; + &::-webkit-scrollbar { + display: none; + } + } + code { white-space: nowrap; color: @core-fleet-black-75; font-family: @code-font; @@ -123,9 +132,77 @@ font-size: 14px; line-height: @text-lineheight; margin-bottom: 0px; - padding-right: 24px; + padding: 0px; + border: none; + } + + [purpose='install-copy-button'], [purpose='command-copy-button'] { + display: none; + background: url('/images/icon-copy-16x16@2x.png'); + font-size: 32px; + position: absolute; + top: 14px; + right: 14px; + color: green; + width: 32px; + padding: 9px; + height: 32px; + background-size: 14px 14px; + background-position: center; + border-radius: 8px; + background-repeat: no-repeat; + cursor: pointer; + &.copied { + display: inline-block; + background: url('/images/icon-copy-clicked-checkmark-32x32@2x.png'); + background-size: 32px 32px; + background-repeat: no-repeat; + background-position: center; + } + + } + &:hover { + [purpose='install-copy-button'], [purpose='command-copy-button'] { + display: inline-block; + &:hover { + background-color: #F2F2F5; + } + } } } + [purpose='tip'] { + margin: 16px 0 32px; + background: #F4F4FF; + padding: 16px; + border-radius: 8px; + display: flex; + img { + display: flex; + margin: 4px 12px 0 0; + height: 16px; + width: 16px; + padding: 0px; + } + p { + display: block; + margin-bottom: 16px; + line-height: 24px; + font-size: 16px; + } + p:last-child { + margin-bottom: 0px; + } + ul { + padding-left: 16px; + } + ul:last-child { + margin-bottom: 0px; + } + li:last-child { + padding-bottom: 0px; + } + } + [purpose='docs-button'] { diff --git a/website/assets/styles/pages/homepage.less b/website/assets/styles/pages/homepage.less index b9f9f5b262..489671a645 100644 --- a/website/assets/styles/pages/homepage.less +++ b/website/assets/styles/pages/homepage.less @@ -643,6 +643,16 @@ } } + [purpose='bottom-cta'] { + h1 { + font-size: 48px; + max-width: 640px; + &.vm { + max-width: unset; + } + } + } + [purpose='video-modal'] { [purpose='modal-dialog'] { width: 100%; @@ -711,11 +721,6 @@ [purpose='integrations-section'] { margin-top: 160px; } - [purpose='bottom-cta'] { - h1 { - font-size: 48px; - } - } [purpose='video-modal'] { [purpose='modal-dialog'] { width: 100%; diff --git a/website/assets/styles/pages/osquery-table-details.less b/website/assets/styles/pages/osquery-table-details.less index 39befd5dc1..b452731b13 100644 --- a/website/assets/styles/pages/osquery-table-details.less +++ b/website/assets/styles/pages/osquery-table-details.less @@ -178,6 +178,7 @@ } [purpose='table-container'] { height: min-content; + max-width: 860px; } [purpose='overflow-shadow'] { @@ -407,7 +408,9 @@ } @media (max-width: 991px) { - + [purpose='table-container'] { + max-width: unset; + } [purpose='schema-table'] { padding-top: 40px; [purpose='platform-logos'] { diff --git a/website/assets/styles/pages/pricing.less b/website/assets/styles/pages/pricing.less index 05cdaeba84..8c25326dcd 100644 --- a/website/assets/styles/pages/pricing.less +++ b/website/assets/styles/pages/pricing.less @@ -21,7 +21,7 @@ line-height: 24px; } a { - color: @core-vibrant-blue; + color: #515774; } .btn { color: #fff; @@ -40,8 +40,8 @@ background: @ui-off-white; position: relative; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.10) inset; - margin-bottom: 40px; - margin-top: 40px; + margin-bottom: 32px; + margin-top: 32px; [purpose='pricing-tier-switch'] { position: absolute; top: -1px; @@ -168,6 +168,13 @@ } } + [purpose='logos-container'] { + margin-top: 40px; + max-width: 100%; + [parasails-component='logo-carousel'] { + margin-bottom: 32px; + } + } [purpose='features-list'] { p { @@ -331,6 +338,7 @@ color: #515774; border-radius: 8px; outline: 1px solid #E2E4EA; + outline-offset: -1px; td { padding: 12px 24px; vertical-align: middle; @@ -353,6 +361,120 @@ } } } + + + [purpose='features-table'] { + .truncated { + max-height: 649px; + overflow-y: hidden; + justify-content: flex-start; + position: relative; + [purpose='truncated-features-fade'] { + position: absolute; + bottom: 0px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.00) -2.2%, #FFF 87.37%); + width: 100%; + height: 101px; + } + } + [purpose='show-all-features-button'] { + cursor: pointer; + color: #515774; + font-size: 14px; + font-weight: 700; + line-height: 150%; + border-radius: 16px; + background: #FFF; + padding: 8px 16px; + margin-bottom: 64px; + span { + height: 12px; + width: 12px; + display: inline-block; + background: url('/images/icon-chevron-fleet-black-75-12x12@2x.png'); + background-size: 12px 12px; + background-repeat: no-repeat; + margin-left: 8px; + } + &:hover { + color: #515774; + background-color: #F9FAFC; + } + &.expanded { + span { + transform: rotate(180deg); + } + } + } + } + [purpose='faq'] { + padding-top: 80px; + border-top: 1px solid #E2E4EA; + margin-bottom: 80px; + [purpose='faq-header'] { + width: 392px; + margin-right: 16px; + } + [purpose='accordion'] { + width: 640px; + flex-grow: 1; + [purpose='accordion-body'] { + [purpose='accordion-header'] { + color: #192147; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-weight: 700; + font-size: 16px; + line-height: 150%; + p { + margin-bottom: 0px; + } + span { + margin-left: 40px; + color: #8B8FA2; + font-weight: 900; + transform: rotate(0deg); + font-size: 19px; + } + &[aria-expanded] { + span { + transform: rotate(180deg); + } + } + &.collapsed { + span { + transform: rotate(0deg); + } + } + } + [purpose='accordion-item'] { + border-bottom: 1px solid #E2E4EA; + margin-top: 24px; + padding-bottom: 12px; + } + [purpose='faq-answer'] { + margin-right: 40px; + margin-bottom: 16px; + a { + text-decoration-line: underline; + } + } + } + } + } + + [purpose='contact-note'] { + margin-top: 24px; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; + a { + text-decoration-line: underline; + } + } [purpose='bottom-cta'] { padding-top: 80px; padding-bottom: 120px; @@ -463,6 +585,71 @@ width: 100%; } } + [purpose='features-table'] { + .truncated { + max-height: 1480px; + overflow-y: hidden; + justify-content: flex-start; + position: relative; + [purpose='truncated-features-fade'] { + position: absolute; + bottom: 0px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.00) -2.2%, #FFF 87.37%); + width: 100%; + height: 101px; + } + } + } + [purpose='faq'] { + padding-top: 80px; + border-top: 1px solid #E2E4EA; + margin-bottom: 80px; + [purpose='faq-header'] { + width: unset; + margin-right: 0px; + margin-bottom: 16px; + } + [purpose='accordion'] { + width: 100%; + [purpose='accordion-body'] { + [purpose='accordion-header'] { + color: #192147; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-weight: 700; + font-size: 16px; + line-height: 150%; + p { + margin-bottom: 0px; + } + span { + margin-left: 40px; + color: #8B8FA2; + font-weight: 900; + transform: rotate(0deg); + font-size: 19px; + } + &[aria-expanded] { + span { + transform: rotate(180deg); + } + } + &.collapsed { + span { + transform: rotate(0deg); + } + } + } + [purpose='accordion-item'] { + border-bottom: 1px solid #E2E4EA; + margin-top: 16px; + margin-bottom: 16px; + } + } + } + } } @media (max-width: 575px) { // >575px: diff --git a/website/assets/styles/pages/start.less b/website/assets/styles/pages/start.less index 9a43754c76..436d6fdb9d 100644 --- a/website/assets/styles/pages/start.less +++ b/website/assets/styles/pages/start.less @@ -2,6 +2,7 @@ background: linear-gradient(180deg, #E8F1F6 0%, #FFF 16.49%); a { color: @core-fleet-black-75; + text-decoration: underline; } h1 { font-size: 32px; @@ -14,6 +15,13 @@ font-weight: 800; line-height: 120%; } + ul { + margin-top: 32px; + padding-inline-start: 16px; + li { + margin-bottom: 24px; + } + } [purpose='logo-container'] { max-width: 524px; margin-left: auto; @@ -29,15 +37,65 @@ } } [purpose='form-container'] { - width: 528px; - margin-left: auto; - margin-right: auto; - } - [purpose='page-container'] { - padding-top: 64px; + min-width: 632px; + max-width: 632px; padding-left: 64px; padding-right: 64px; - padding-bottom: 64px; + padding-top: 32px; + margin-right: 20px; + } + [purpose='form-tip'] { + margin-top: 32px; + padding: 16px; + border-radius: 8px; + border: 1px solid #E6E3D0; + background: #FFFEF9; + p { + margin-bottom: 0px; + font-size: 14px; + line-height: 150%; + } + img { + height: 32px; + width: 32px; + margin-right: 12px; + } + } + [purpose='form-image'] { + width: 100%; + height: 680px; + max-width: 568px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border-radius: 16px; + &.stage-one { + background-image: url('/images/psystage-1-unaware-558x680@2x.png'); + } + &.stage-two { + background-image: url('/images/psystage-2-aware-508x784@2x.png'); + } + &.stage-three { + background-image: url('/images/psystage-3-intrigued-558x680@2x.png'); + } + &.stage-four { + background-image: url('/images/psystage-4-has-use-case-508x784@2x.png'); + } + &.stage-five { + background-image: url('/images/cropped-fleet-cloud-city-504x784@2x.png'); + } + &.stage-six { + background-image: url('/images/psystage-6-has-team-buy-in-504x784@2x.png'); + } + &.cloud-city { + background-image: url('/images/cropped-fleet-cloud-city-504x784@2x.png'); + } + } + [purpose='page-container'] { + padding-top: 20px; + padding-left: 20px; + padding-right: 24px; + padding-bottom: 24px; max-width: unset; display: flex; flex-direction: column; @@ -49,7 +107,7 @@ align-items: center; margin-bottom: 32px; img { - height: 18px; + height: 16px; display: inline; margin-left: 10px; } @@ -140,6 +198,9 @@ align-items: center; justify-content: start; user-select: none; + a { + text-decoration: none; + } } [purpose='submit-button'] { padding: 12px; @@ -208,6 +269,9 @@ } } + [parasails-component='logo-carousel'] { + margin-bottom: 0px; + } [purpose='quote'] { display: flex; padding: 32px; @@ -259,6 +323,16 @@ padding-right: 40px; max-width: 600px; } + [purpose='form-container'] { + padding: 0; + min-width: unset; + max-width: 504px; + margin-left: auto; + margin-right: auto; + } + [purpose='form-image'] { + display: none; + } } @media (max-width: 768px) { @@ -267,7 +341,6 @@ padding-left: 24px; padding-right: 24px; } - } @media (max-width: 575px) { [purpose='logo-container'] { @@ -284,7 +357,7 @@ margin-bottom: 20px; } [purpose='form-container'] { - width: unset; + width: 100%; margin-left: 0; margin-right: 0; } diff --git a/website/assets/styles/pages/vulnerability-management.less b/website/assets/styles/pages/vulnerability-management.less index 17a1504178..87998dd3d2 100644 --- a/website/assets/styles/pages/vulnerability-management.less +++ b/website/assets/styles/pages/vulnerability-management.less @@ -46,7 +46,7 @@ [purpose='page-headline'] { padding-bottom: 80px; - width: 680px; + max-width: 780px; h2 { font-size: 48px; font-style: normal; diff --git a/website/config/custom.js b/website/config/custom.js index ea67c02aef..249d5238e0 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -128,6 +128,7 @@ module.exports.custom = { // 'docs/Contributing/API-for-contributors.md': '', // « Covered in CODEOWNERS (2023-07-22) // 'schema': '', // « Covered in CODEOWNERS (2023-07-22) 'docs/01-Using-Fleet/standard-query-library/standard-query-library.yml': 'rachaelshaw', //« Built-in queries + '/docs/get-started/faq': 'zayhanlon', 'ee/cis': 'sharon-fdm',//« Fleet Premium only: built-in queries (built-in policies for CIS benchmarks) -- FYI: On 2023-07-15, we changed this so that Sharon, Lucas, and Rachel are all maintainers, but where there is a single DRI who is automatically requested approval from. // 🫧 Articles and release notes @@ -155,6 +156,9 @@ module.exports.custom = { 'ee/vulnerability-dashboard/scripts': 'eashaw', 'ee/vulnerability-dashboard/package.json': 'eashaw', + // 🫧 Bulk operations dashboard + 'ee/bulk-operations-dashboard': 'eashaw',// (catch-all) + // 🫧 Pricing and features // 'website/views/pages/pricing.ejs': '', // « Covered in CODEOWNERS (2023-07-22) // 'handbook/company/pricing-features-table.yml': '', // « Covered in CODEOWNERS (2023-07-22) @@ -205,6 +209,7 @@ module.exports.custom = { // Reference, config surface, built-in queries, API, and other documentation 'docs': ['rachaelshaw', 'noahtalerman', 'eashaw'],// (default for docs) 'docs/01-Using-Fleet/standard-query-library/standard-query-library.yml': ['rachaelshaw', 'noahtalerman', 'eashaw'],// (standard query library) + '/docs/get-started/faq': ['ksatter', 'ddribeiro', 'zayhanlon'], 'docs/REST API/rest-api.md': ['rachaelshaw', 'lukeheath'],// (standard query library) 'schema': ['eashaw'],// (Osquery table schema) 'ee/cis': ['lukeheath', 'sharon-fdm', 'lucasmrod', 'rachelElysia', 'rachaelshaw'], @@ -232,6 +237,9 @@ module.exports.custom = { 'ee/vulnerability-dashboard/config/routes.js': 'eashaw', 'ee/vulnerability-dashboard/package.json': 'eashaw', + // 🫧 Bulk operations dashboard + 'ee/bulk-operations-dashboard': 'eashaw', + // Other brandfronts 'README.md': ['mikermcneil', 'mike-j-thomas', 'lukeheath'],//« github brandfront (github.com/fleetdm/fleet) 'tools/fleetctl-npm/README.md': ['mikermcneil', 'mike-j-thomas', 'lukeheath'],//« brandfront for fleetctl package on npm (npmjs.com/package/fleetctl) @@ -258,7 +266,7 @@ module.exports.custom = { 'handbook/company/product-groups.md': ['lukeheath', 'sampfluger88','mikermcneil'], 'handbook/company/open-positions.yml': ['@sampfluger88','mikermcneil'], 'handbook/digital-experience': ['sampfluger88','mikermcneil'], - 'handbook/business-operations': ['sampfluger88','mikermcneil'], + 'handbook/finance': ['sampfluger88','mikermcneil'], 'handbook/engineering': ['sampfluger88','mikermcneil', 'lukeheath'], 'handbook/product-design': ['sampfluger88','mikermcneil'], 'handbook/sales': ['sampfluger88','mikermcneil'], diff --git a/website/config/routes.js b/website/config/routes.js index 59b068e650..57d1c0608b 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -331,7 +331,6 @@ module.exports.routes = { 'GET /use-cases/using-elasticsearch-and-kibana-to-visualize-osquery-performance': '/guides/using-elasticsearch-and-kibana-to-visualize-osquery-performance', 'GET /use-cases/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think': '/securing/work-may-be-watching-but-it-might-not-be-as-bad-as-you-think', 'GET /docs/contributing/testing': '/docs/contributing/testing-and-local-development', - 'GET /handbook/people': '/handbook/business-operations', 'GET /handbook/people/ceo-handbook': '/handbook/ceo', 'GET /handbook/company/ceo-handbook': '/handbook/ceo', 'GET /handbook/growth': '/handbook/marketing#growth', @@ -351,8 +350,8 @@ module.exports.routes = { 'GET /device-management/fleet-user-stories-f100': '/success-stories/fleet-user-stories-wayfair', 'GET /device-management/fleet-user-stories-schrodinger': '/success-stories/fleet-user-stories-wayfair', 'GET /device-management/fleet-user-stories-wayfair': '/success-stories/fleet-user-stories-wayfair', - 'GET /handbook/security': '/handbook/business-operations/security', - 'GET /handbook/security/security-policies':'/handbook/business-operations/security-policies#information-security-policy-and-acceptable-use-policy',// « reasoning: https://github.com/fleetdm/fleet/pull/9624 + 'GET /handbook/security': '/handbook/digital-experience/security', + 'GET /handbook/security/security-policies':'/handbook/digital-experience/security-policies#information-security-policy-and-acceptable-use-policy',// « reasoning: https://github.com/fleetdm/fleet/pull/9624 'GET /handbook/handbook': '/handbook/company/handbook', 'GET /handbook/company/development-groups': '/handbook/company/product-groups', 'GET /docs/using-fleet/mdm-macos-settings': '/docs/using-fleet/mdm-custom-macos-settings', @@ -363,11 +362,11 @@ module.exports.routes = { 'GET /handbook/marketing': '/handbook/demand/', 'GET /handbook/customers': '/handbook/sales/', 'GET /handbook/product': '/handbook/product-design', + 'GET /handbook/business-operations': '/handbook/finance', 'GET /docs': '/docs/get-started/why-fleet', 'GET /docs/get-started': '/docs/get-started/why-fleet', 'GET /docs/rest-api': '/docs/rest-api/rest-api', - 'GET /docs/using-fleet': '/docs/using-fleet/fleet-ui', 'GET /docs/configuration': '/docs/configuration/fleet-server-configuration', 'GET /docs/contributing': 'https://github.com/fleetdm/fleet/tree/main/docs/Contributing', 'GET /docs/deploy': '/docs/deploy/introduction', @@ -380,8 +379,8 @@ module.exports.routes = { 'GET /docs/using-fleet/chromeos': '/docs/using-fleet/enroll-chromebooks', 'GET /docs/using-fleet/rest-api': '/docs/rest-api/rest-api', 'GET /docs/using-fleet/configuration-files': '/docs/configuration/configuration-files/', - 'GET /docs/using-fleet/application-security': '/handbook/business-operations/application-security', - 'GET /docs/using-fleet/security-audits': '/handbook/business-operations/security-audits', + 'GET /docs/using-fleet/application-security': '/handbook/digital-experience/application-security', + 'GET /docs/using-fleet/security-audits': '/handbook/digital-experience/security-audits', 'GET /docs/using-fleet/process-file-events': '/guides/querying-process-file-events-table-on-centos-7', 'GET /docs/using-fleet/audit-activities': '/docs/using-fleet/audit-logs', 'GET /docs/using-fleet/detail-queries-summary': '/docs/using-fleet/understanding-host-vitals', @@ -451,6 +450,37 @@ module.exports.routes = { return res.redirect('/tables/'+req.param('tableName')); } }, + 'GET /docs/using-fleet/fleet-ui': (req,res)=> { return res.redirect(301, '/guides/queries');}, + 'GET /docs/using-fleet/learn-how-to-use-fleet': (req,res)=> { return res.redirect(301, '/guides/queries');}, + 'GET /docs/using-fleet/fleetctl-cli': (req,res)=> { return res.redirect(301, '/guides/fleetctl');}, + 'GET /docs/using-fleet/fleet-desktop': (req,res)=> { return res.redirect(301, '/guides/fleet-desktop');}, + 'GET /docs/using-fleet/enroll-hosts': (req,res)=> { return res.redirect(301, '/guides/enroll-hosts');}, + 'GET /docs/using-fleet/manage-access': (req,res)=> { return res.redirect(301, '/guides/role-based-access');}, + 'GET /docs/using-fleet/segment-hosts': (req,res)=> { return res.redirect(301, '/guides/teams');}, + 'GET /docs/using-fleet/supported-browsers': (req,res)=> { return res.redirect(301, '/docs/get-started/faq');}, + 'GET /docs/using-fleet/supported-host-operating-systems': (req,res)=> { return res.redirect(301, '/docs/get-started/faq');}, + 'GET /docs/using-fleet/gitops': (req,res)=> { return res.redirect(301, '/docs/configuration/yaml-files');}, + 'GET /docs/using-fleet/mdm-setup': (req,res)=> { return res.redirect(301, '/guides/macos-mdm-setup');}, + 'GET /docs/using-fleet/mdm-migration-guide': (req,res)=> { return res.redirect(301, '/guides/mdm-migration');}, + 'GET /docs/using-fleet/mdm-os-updates': (req,res)=> { return res.redirect(301, '/guides/enforce-os-updates');}, + 'GET /docs/using-fleet/mdm-disk-encryption': (req,res)=> { return res.redirect(301, '/guides/enforce-disk-encryption');}, + 'GET /docs/using-fleet/mdm-custom-os-settings': (req,res)=> { return res.redirect(301, '/guides/custom-os-settings');}, + 'GET /docs/using-fleet/mdm-macos-setup-experience': (req,res)=> { return res.redirect(301, '/guides/macos-setup-experience');}, + 'GET /docs/using-fleet/scripts': (req,res)=> { return res.redirect(301, '/guides/scripts');}, + 'GET /docs/using-fleet/automations': (req,res)=> { return res.redirect(301, '/guides/automations');}, + 'GET /docs/using-fleet/puppet-module': (req,res)=> { return res.redirect(301, '/guides/puppet-module');}, + 'GET /docs/using-fleet/vulnerability-processing': (req,res)=> { return res.redirect(301, '/guides/vulnerability-processing');}, + 'GET /docs/using-fleet/cis-benchmarks': (req,res)=> { return res.redirect(301, '/guides/cis-benchmarks');}, + 'GET /docs/using-fleet/osquery-process': (req,res)=> { return res.redirect(301, '/guides/osquery-watchdog');}, + 'GET /docs/using-fleet/update-agents': (req,res)=> { return res.redirect(301, '/guides/fleetd-updates');}, + 'GET /docs/using-fleet/usage-statistics': (req,res)=> { return res.redirect(301, '/guides/fleet-usage-statistics');}, + 'GET /docs/using-fleet/downgrading-fleet': (req,res)=> { return res.redirect(301, '/guides/downgrade-fleet');}, + 'GET /docs/using-fleet/enroll-chromebooks': (req,res)=> { return res.redirect(301, '/guides/chrome-os');}, + 'GET /docs/using-fleet/audit-logs': (req,res)=> { return res.redirect(301, 'https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Audit-logs.md');}, + 'GET /docs/using-fleet/understanding-host-vitals': (req,res)=> { return res.redirect(301, 'https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Understanding-host-vitals.md');}, + 'GET /docs/using-fleet/standard-query-library': (req,res)=> { return res.redirect(301, '/guides/standard-query-library');}, + 'GET /docs/using-fleet/mdm-commands': (req,res)=> { return res.redirect(301, '/guides/mdm-commands');}, + 'GET /docs/using-fleet/log-destinations': (req,res)=> { return res.redirect(301, '/guides/log-destinations');}, // ╔╦╗╦╔═╗╔═╗ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ ┬ ╔╦╗╔═╗╦ ╦╔╗╔╦ ╔═╗╔═╗╔╦╗╔═╗ // ║║║║╚═╗║ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ ╚═╗ ┌┼─ ║║║ ║║║║║║║║ ║ ║╠═╣ ║║╚═╗ @@ -473,14 +503,14 @@ module.exports.routes = { 'GET /legal': '/legal/terms', 'GET /terms': '/legal/terms', 'GET /handbook/security/github': '/handbook/security#git-hub-security', - 'GET /slack': 'https://join.slack.com/t/osquery/shared_invite/zt-1wkw5fzba-lWEyke60sjV6C4cdinFA1w',// Note: This redirect is used on error pages and email templates in the Fleet UI. + 'GET /slack': 'https://join.slack.com/t/osquery/shared_invite/zt-2op37v6qp-aVPivU5xB_FwuYElN0Z1lw',// Note: This redirect is used on error pages and email templates in the Fleet UI. 'GET /docs/using-fleet/updating-fleet': '/docs/deploying/upgrading-fleet', 'GET /blog': '/articles', 'GET /brand': '/logos', 'GET /get-started': '/try-fleet', 'GET /g': (req,res)=> { let originalQueryStringWithAmp = req.url.match(/\?(.+)$/) ? '&'+req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/?meet-fleet'+originalQueryStringWithAmp); }, 'GET /test-fleet-sandbox': '/register', - 'GET /unsubscribe': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/api/v1/unsubscribe-from-all-newsletters?'+originalQueryString);}, + 'GET /unsubscribe': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/api/v1/unsubscribe-from-marketing-emails?'+originalQueryString);}, 'GET /tables': '/tables/account_policy_data', 'GET /imagine/launch-party': 'https://www.eventbrite.com/e/601763519887', 'GET /blackhat2023': 'https://github.com/fleetdm/fleet/tree/main/tools/blackhat-mdm', // Assets from @marcosd4h & @zwass Black Hat 2023 talk @@ -529,6 +559,11 @@ module.exports.routes = { 'GET /learn-more-about/host-identifiers': '/docs/rest-api/rest-api#get-host-by-identifier', 'GET /learn-more-about/uninstall-fleetd': '/docs/using-fleet/faq#how-can-i-uninstall-fleetd', 'GET /learn-more-about/vulnerability-processing': '/docs/using-fleet/vulnerability-processing', + 'GET /learn-more-about/dep-profile': 'https://developer.apple.com/documentation/devicemanagement/define_a_profile', + 'GET /learn-more-about/apple-business-manager-tokens-api': '/docs/rest-api/rest-api#list-apple-business-manager-abm-tokens', + 'GET /learn-more-about/apple-business-manager-teams-api': 'https://github.com/fleetdm/fleet/blob/main/docs/Contributing/API-for-contributors.md#update-abm-tokens-teams', + 'GET /learn-more-about/apple-business-manager-gitops': '/docs/using-fleet/gitops#apple-business-manager', + 'GET /learn-more-about/s3-bootstrap-package': '/docs/configuration/fleet-server-configuration#s-3-software-installers-bucket', // Sitemap // ============================================================================================================= @@ -557,7 +592,7 @@ module.exports.routes = { 'GET /defcon': 'https://kqphpqst851.typeform.com/to/Y6NYxM5A', 'GET /osquery-stickers': 'https://kqphpqst851.typeform.com/to/JxJ8YnxG', 'GET /swag': 'https://kqphpqst851.typeform.com/to/Y6NYxM5A', - 'GET /community': 'https://join.slack.com/t/osquery/shared_invite/zt-1wkw5fzba-lWEyke60sjV6C4cdinFA1w', + 'GET /community': 'https://join.slack.com/t/osquery/shared_invite/zt-2op37v6qp-aVPivU5xB_FwuYElN0Z1lw', // Temporary redirects // ============================================================================================================= @@ -605,4 +640,5 @@ module.exports.routes = { 'POST /api/v1/save-questionnaire-progress': { action: 'save-questionnaire-progress' }, 'POST /api/v1/account/update-start-cta-visibility': { action: 'account/update-start-cta-visibility' }, 'POST /api/v1/deliver-deal-registration-submission': { action: 'deliver-deal-registration-submission' }, + '/api/v1/unsubscribe-from-marketing-emails': { action: 'unsubscribe-from-marketing-emails' }, }; diff --git a/website/package.json b/website/package.json index a07935e6c1..da6d9abee2 100644 --- a/website/package.json +++ b/website/package.json @@ -13,7 +13,7 @@ "moment": "2.29.4", "sails": "^1.5.11", "sails-hook-apianalytics": "^2.0.6", - "sails-hook-organics": "^2.2.2", + "sails-hook-organics": "^3.0.0", "sails-hook-orm": "^4.0.3", "sails-hook-sockets": "^3.0.0", "sails-postgresql": "^5.0.1" diff --git a/website/scripts/build-static-content.js b/website/scripts/build-static-content.js index 03050e00f0..eaa40900b3 100644 --- a/website/scripts/build-static-content.js +++ b/website/scripts/build-static-content.js @@ -16,7 +16,7 @@ module.exports = { fn: async function ({ dry, githubAccessToken }) { let path = require('path'); let YAML = require('yaml'); - + let util = require('util'); // FUTURE: If we ever need to gather source files from other places or branches, etc, see git history of this file circa 2021-05-19 for an example of a different strategy we might use to do that. let topLvlRepoPath = path.resolve(sails.config.appPath, '../'); @@ -390,11 +390,29 @@ module.exports = { }//fi // Get last modified timestamp using git, and represent it as a JS timestamp. - // > Inspired by https://github.com/uncletammy/doc-templater/blob/2969726b598b39aa78648c5379e4d9503b65685e/lib/compile-markdown-tree-from-remote-git-repo.js#L265-L273 - let lastModifiedAt = (new Date((await sails.helpers.process.executeCommand.with({ - command: `git log -1 --format="%ai" '${path.relative(topLvlRepoPath, pageSourcePath)}'`, - dir: topLvlRepoPath, - })).stdout)).getTime(); + let lastModifiedAt; + if(!githubAccessToken) { + lastModifiedAt = Date.now(); + } else if(process.env.GITHUB_REF_NAME && process.env.GITHUB_REF_NAME === 'main') {// Only add lastModifiedAt timestamps if this test is running on the main branch + let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + url: 'https://api.github.com/repos/fleetdm/fleet/commits', + data: { + path: path.join(sectionRepoPath, pageRelSourcePath), + page: 1, + per_page: 1,//eslint-disable-line camelcase + }, + headers: baseHeadersForGithubRequests, + }).intercept((err)=>{ + return new Error(`When getting the commit history for ${path.join(sectionRepoPath, pageRelSourcePath)} to get a lastModifiedAt timestamp, an error occured.`, err); + }); + // The value we'll use for the lastModifiedAt timestamp will be date value of the `commiter` property of the `commit` we got in the API response from github. + let mostRecentCommitToThisFile = responseData[0]; + if(!mostRecentCommitToThisFile.commit || !mostRecentCommitToThisFile.commit.committer) { + // Throw an error if the the response from GitHub is missing a commit or commiter. + throw new Error(`When getting the commit history for ${path.join(sectionRepoPath, pageRelSourcePath)} to get a lastModifiedAt timestamp, the response from the GitHub API did not include information about the most recent commit. Response from GitHub: ${util.inspect(responseData, {depth:null})}`); + } + lastModifiedAt = (new Date(mostRecentCommitToThisFile.commit.committer.date)).getTime(); // Convert the UTC timestamp from GitHub to a JS timestamp. + } // Determine display title (human-readable title) to use for this page. let pageTitle; @@ -560,11 +578,30 @@ module.exports = { let RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO = 'handbook/company/open-positions.yml'; // Get last modified timestamp using git, and represent it as a JS timestamp. - // > Inspired by https://github.com/uncletammy/doc-templater/blob/2969726b598b39aa78648c5379e4d9503b65685e/lib/compile-markdown-tree-from-remote-git-repo.js#L265-L273 - let lastModifiedAt = (new Date((await sails.helpers.process.executeCommand.with({ - command: `git log -1 --format="%ai" '${path.join(topLvlRepoPath, RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO)}'`, - dir: topLvlRepoPath, - })).stdout)).getTime(); + let lastModifiedAt; + if(!githubAccessToken) { + lastModifiedAt = Date.now(); + } else { + // If we're including a lastModifiedAt value for schema tables, we'll send a request to the GitHub API to get a timestamp of when the last commit + let responseData = await sails.helpers.http.get.with({// [?]: https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + url: 'https://api.github.com/repos/fleetdm/fleet/commits', + data: { + path: RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO, + page: 1, + per_page: 1,//eslint-disable-line camelcase + }, + headers: baseHeadersForGithubRequests, + }).intercept((err)=>{ + return new Error(`When getting the commit history for the open positions YAML to get a lastModifiedAt timestamp, an error occured.`, err); + }); + // The value we'll use for the lastModifiedAt timestamp will be date value of the `commiter` property of the `commit` we got in the API response from github. + let mostRecentCommitToThisFile = responseData[0]; + if(!mostRecentCommitToThisFile.commit || !mostRecentCommitToThisFile.commit.committer) { + // Throw an error if the the response from GitHub is missing a commit or commiter. + throw new Error(`When trying to get a lastModifiedAt timestamp for the open positions YAML, the response from the GitHub API did not include information about the most recent commit. Response from GitHub: ${util.inspect(responseData, {depth:null})}`); + } + lastModifiedAt = (new Date(mostRecentCommitToThisFile.commit.committer.date)).getTime(); // Convert the UTC timestamp from GitHub to a JS timestamp. + } let openPositionsYaml = await sails.helpers.fs.read(path.join(topLvlRepoPath, RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO)).intercept('doesNotExist', (err)=>new Error(`Could not find open positions YAML file at "${RELATIVE_PATH_TO_OPEN_POSITIONS_YML_IN_FLEET_REPO}". Was it accidentally moved? Raw error: `+err.message)); let openPositionsToCreatePartialsFor = YAML.parse(openPositionsYaml, {prettyErrors: true}); @@ -673,7 +710,12 @@ module.exports = { } // After we build the Markdown pages, we'll merge the osquery schema with the Fleet schema overrides, then create EJS partials for each table in the merged schema. - let expandedTables = await sails.helpers.getExtendedOsquerySchema.with({includeLastModifiedAtValue: true}); + let expandedTables; + if(githubAccessToken){ + expandedTables = await sails.helpers.getExtendedOsquerySchema.with({includeLastModifiedAtValue: true, githubAccessToken,}); + } else { + expandedTables = await sails.helpers.getExtendedOsquerySchema(); + } // Once we have our merged schema, we'll create ejs partials for each table. for(let table of expandedTables) { @@ -803,8 +845,15 @@ module.exports = { let yaml = await sails.helpers.fs.read(path.join(topLvlRepoPath, RELATIVE_PATH_TO_PRICING_TABLE_YML_IN_FLEET_REPO)).intercept('doesNotExist', (err)=>new Error(`Could not find pricing table features YAML file at "${RELATIVE_PATH_TO_PRICING_TABLE_YML_IN_FLEET_REPO}". Was it accidentally moved? Raw error: `+err.message)); let pricingTableFeatures = YAML.parse(yaml, {prettyErrors: true}); let VALID_PRODUCT_CATEGORIES = ['Endpoint operations', 'Device management', 'Vulnerability management']; - let VALID_PRICING_TABLE_CATEGORIES = ['Support', 'Deployment', 'Integrations', 'Endpoint operations', 'Device management', 'Vulnerability management']; + let VALID_PRICING_TABLE_CATEGORIES = ['Support', 'Deployment', 'Integrations', 'Configuration', 'Devices', 'Vulnerability management']; + let VALID_PRICING_TABLE_KEYS = ['industryName', 'description', 'documentationUrl', 'tier', 'jamfProHasFeature', 'jamfProtectHasFeature', 'usualDepartment', 'productCategories', 'pricingTableCategories', 'waysToUse', 'buzzwords', 'demos', 'dri', 'friendlyName', 'moreInfoUrl', 'comingSoonOn', 'screenshotSrc', 'isExperimental']; for(let feature of pricingTableFeatures){ + // Throw an error if a feature contains an unrecognized key. + for(let key of _.keys(feature)){ + if(!VALID_PRICING_TABLE_KEYS.includes(key)){ + throw new Error(`Unrecognized key. Could not build pricing table config from pricing-features-table.yml. The "${feature.industryName}" feature contains an unrecognized key (${key}). To resolve, fix any typos or remove this key and try running this script again.`); + } + } if(feature.name) {// Compatibility check throw new Error(`Could not build pricing table config from pricing-features-table.yml. A feature has a "name" (${feature.name}) which is no longer supported. To resolve, add a "industryName" to this feature: ${feature}`); } @@ -1091,7 +1140,6 @@ module.exports = { } }); } - } diff --git a/website/scripts/deliver-nurture-emails.js b/website/scripts/deliver-nurture-emails.js index 552c75b0a5..7b682460e9 100644 --- a/website/scripts/deliver-nurture-emails.js +++ b/website/scripts/deliver-nurture-emails.js @@ -52,7 +52,8 @@ module.exports = { template: 'email-nurture-stage-three', layout: 'layout-nurture-email', templateData: { - firstName: user.firstName + firstName: user.firstName, + emailAddress: user.emailAddress }, to: user.emailAddress, toName: `${user.firstName} ${user.lastName}`, @@ -76,20 +77,26 @@ module.exports = { if(user.psychologicalStageLastChangedAt > oneDayAgoAt) { continue; } else { - await sails.helpers.sendTemplateEmail.with({ - template: 'email-nurture-stage-four', - layout: 'layout-nurture-email', - templateData: { - firstName: user.firstName - }, - to: user.emailAddress, - toName: `${user.firstName} ${user.lastName}`, - subject: 'Deploy open-source MDM', - bcc: [sails.config.custom.activityCaptureEmailForNutureEmails], - from: sails.config.custom.contactEmailForNutureEmails, - fromName: sails.config.custom.contactNameForNurtureEmails, - ensureAck: true, - }); + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // Note: We commented this out because it was interfering with the ability for leads to flow + // without making reps wait. We can turn it back on when we have a way for Drew to disable + // nurture emails on a per-contact basis from Salesforce. + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + // await sails.helpers.sendTemplateEmail.with({ + // template: 'email-nurture-stage-four', + // layout: 'layout-nurture-email', + // templateData: { + // firstName: user.firstName, + // emailAddress: user.emailAddress + // }, + // to: user.emailAddress, + // toName: `${user.firstName} ${user.lastName}`, + // subject: 'Deploy open-source MDM', + // bcc: [sails.config.custom.activityCaptureEmailForNutureEmails], + // from: sails.config.custom.contactEmailForNutureEmails, + // fromName: sails.config.custom.contactNameForNurtureEmails, + // ensureAck: true, + // }); emailedStageFourUserIds.push(user.id); } } @@ -109,7 +116,8 @@ module.exports = { template: 'email-nurture-stage-five', layout: 'layout-nurture-email', templateData: { - firstName: user.firstName + firstName: user.firstName, + emailAddress: user.emailAddress }, to: user.emailAddress, toName: `${user.firstName} ${user.lastName}`, diff --git a/website/scripts/get-bug-and-pr-report.js b/website/scripts/get-bug-and-pr-report.js index 5894be6ecb..06791bf10a 100644 --- a/website/scripts/get-bug-and-pr-report.js +++ b/website/scripts/get-bug-and-pr-report.js @@ -33,8 +33,10 @@ module.exports = { let daysSinceReleasedBugsWereOpened = []; let allBugsWithUnreleasedLabel = []; let allBugsWithReleasedLabel = []; + let allBugs32DaysOrOlder = []; let allBugsCreatedInPastWeek = []; let allBugsClosedInPastWeek = []; + let allBugsReportedByCustomersInPastWeek = []; let daysSincePullRequestsWereOpened = []; let daysSinceContributorPullRequestsWereOpened = []; let commitToMergeTimesInDays = []; @@ -44,7 +46,7 @@ module.exports = { let allNonPublicOpenPrs = []; let nonPublicPrsClosedInThePastThreeWeeks = []; - // Product group KPIS + // Endpoint operations let allBugsCreatedInPastWeekEndpointOps = []; @@ -103,8 +105,16 @@ module.exports = { let timeOpenInMS = Math.abs(todaysDate - issueOpenedOn); // Convert the miliseconds to days and add the value to the daysSinceBugsWereOpened array let timeOpenInDays = timeOpenInMS / ONE_DAY_IN_MILLISECONDS; + if (timeOpenInDays >= 32) { + allBugs32DaysOrOlder.push(issue); + } if (timeOpenInDays <= 7) { + // All bugs in past week allBugsCreatedInPastWeek.push(issue); + // Customer-reported bugs + if (issue.labels.some(label => label.name.indexOf('customer-') >= 0)) { + allBugsReportedByCustomersInPastWeek.push(issue); + } // Get Endpoint Ops KPIs if (issue.labels.some(label => label.name === '#g-endpoint-ops')) { allBugsCreatedInPastWeekEndpointOps.push(issue); @@ -132,6 +142,7 @@ module.exports = { } } } + daysSinceBugsWereOpened.push(timeOpenInDays); // Send to released or unreleased bugs array if (issue.labels.some(label => label.name === '~unreleased bug')) { @@ -316,8 +327,8 @@ module.exports = { // async()=>{ - // Fetch confidential and classified PRs (current open, and recent closed) - for (let repoName of ['classified', 'confidential']) { + // Fetch confidential PRs (current open, and recent closed) + for (let repoName of ['confidential']) { // [?] https://docs.github.com/en/free-pro-team@latest/rest/pulls/pulls#list-pull-requests let openPrs = await sails.helpers.http.get(`https://api.github.com/repos/fleetdm/${encodeURIComponent(repoName)}/pulls`, { state: 'open', @@ -380,25 +391,12 @@ module.exports = { // 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, + allBugs32DaysOrOlder.length, + allBugsReportedByCustomersInPastWeek.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); + allBugsClosedInPastWeek.length,); // Log the results sails.log(` @@ -407,17 +405,19 @@ module.exports = { --------------------------- ${kpiResults.join(',')} - 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. + Note: Copy the values above, then paste into Google KPI sheet and select "Split text to columns" to split the values into separate columns. 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} Average open time (all PRs): ${averageDaysPullRequestsAreOpenFor} days. + Number of open pull requests in the fleetdm/fleet Github repo: ${daysSincePullRequestsWereOpened.length} - Bugs (part 1): + Bugs: --------------------------- Average open time (released bugs): ${averageNumberOfDaysReleasedBugsAreOpenFor} days. @@ -429,6 +429,12 @@ module.exports = { Number of issues with the "bug" label opened in the past week: ${allBugsCreatedInPastWeek.length} + 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} + + Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} + Endpoint Operations: --------------------------- Number of issues with the "#g-endpoint-ops" and "bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOps.length} @@ -449,17 +455,10 @@ module.exports = { Number of issues with the "#g-mdm", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length} - Bugs (part 2): - --------------------------- - 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} - - Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} - Pull requests requiring CEO review --------------------------------------- Number of open ~ceo pull requests in the fleetdm Github org: ${ceoDependentOpenPrs.length} + Average open time (~ceo PRs): ${Math.round(ceoDependentPrOpenTime*100)/100} days. `); diff --git a/website/scripts/migrate-lead-source-to-contact-source.js b/website/scripts/migrate-lead-source-to-contact-source.js new file mode 100644 index 0000000000..407e1bee09 --- /dev/null +++ b/website/scripts/migrate-lead-source-to-contact-source.js @@ -0,0 +1,44 @@ +module.exports = { + + + friendlyName: 'Migrate lead source to contact source', + + + description: '', + + + fn: async function () { + + sails.log('Running custom shell script... (`sails run migrate-lead-source-to-contact-source`)'); + + require('assert')(sails.config.custom.salesforceIntegrationUsername); + require('assert')(sails.config.custom.salesforceIntegrationPasskey); + + // Log in to Salesforce. + let jsforce = require('jsforce'); + let salesforceConnection = new jsforce.Connection({ + loginUrl : 'https://fleetdm.my.salesforce.com' + }); + await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); + + let POSSIBLE_CONTACT_SOURCES = ['Dripify', 'Website - Contact forms', 'Website - Sign up', 'Website - Swag request', 'Manual research', 'Initial qualification meeting']; + let contacts = ( + await salesforceConnection.query(`SELECT Id, LeadSource, FirstName FROM Contact WHERE Contact_source__c = NULL AND LeadSource IN (${POSSIBLE_CONTACT_SOURCES.map((src)=>'\''+src+'\'').join(', ')})`) + // await salesforceConnection.query(`SELECT Id, LeadSource, FirstName FROM Contact WHERE LastName = 'McNeil' AND FirstName IN (${['Mike'].map((src)=>'\''+src+'\'').join(', ')}) AND LeadSource IN (${POSSIBLE_CONTACT_SOURCES.map((src)=>'\''+src+'\'').join(', ')})`) + ).records;// « unpack the sausage + // console.log(contacts); + + await sails.helpers.flow.simultaneouslyForEach(contacts, async (contact)=>{ + // console.log(`${contact.FirstName} :: ${contact.LeadSource}`); + await salesforceConnection.sobject('Contact').update({ + Id: contact.Id, + Contact_source__c: contact.LeadSource//eslint-disable-line camelcase + }); + });//∞ + + + } + + +}; + diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js index 959c3625b5..f13826b866 100644 --- a/website/scripts/send-aggregated-metrics-to-datadog.js +++ b/website/scripts/send-aggregated-metrics-to-datadog.js @@ -406,6 +406,69 @@ module.exports = { }], tags: [`enabled:false`], }); + // aiFeaturesDisabled + let numberOfInstancesWithAiFeaturesDisabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {aiFeaturesDisabled: true}).length; + let numberOfInstancesWithAiFeaturesEnabled = numberOfInstancesToReport - numberOfInstancesWithAiFeaturesDisabled; + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsEnabled + let numberOfInstancesWithMaintenanceWindowsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithMaintenanceWindowsDisabled = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsConfigured + let numberOfInstancesWithMaintenanceWindowsConfigured = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsConfigured; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsConfigured + }], + tags: [`configured:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithoutMaintenanceWindowsConfigured + }], + tags: [`configured:false`], + }); // Create two metrics to track total number of hosts reported in the last week. let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'premium'}), 'numHostsEnrolled')); diff --git a/website/views/emails/email-contact-form.ejs b/website/views/emails/email-contact-form.ejs new file mode 100644 index 0000000000..0e8f8bf8ee --- /dev/null +++ b/website/views/emails/email-contact-form.ejs @@ -0,0 +1,15 @@ +<% /* Note: This is NOT injected into an email layout` */ %> +
    +
    +
    + Logo +
    +
    +

    Message:

    +
    <%= message %>
    +

    Submitter information:

    +

    Name: <%= firstName %> <%= lastName %>

    +

    Email address: <%= emailAddress %>

    +
    +
    +
    diff --git a/website/views/layouts/layout-nurture-email.ejs b/website/views/layouts/layout-nurture-email.ejs index 007880839b..5c4df42a0e 100644 --- a/website/views/layouts/layout-nurture-email.ejs +++ b/website/views/layouts/layout-nurture-email.ejs @@ -18,5 +18,6 @@

    © <%= (new Date()).getFullYear() %> Fleet Inc.
    All trademarks are the property of their respective owners.

    + Unsubscribe diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index ed4ebaf20b..c8a438213f 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -55,6 +55,8 @@ !function(r){var e={};function o(n){if(e[n])return e[n].exports;var t=e[n]={i:n,l:!1,exports:{}};return r[n].call(t.exports,t,t.exports,o),t.l=!0,t.exports}o.m=r,o.c=e,o.d=function(r,e,n){o.o(r,e)||Object.defineProperty(r,e,{enumerable:!0,get:n})},o.r=function(r){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(r,"__esModule",{value:!0})},o.t=function(r,e){if(1&e&&(r=o(r)),8&e)return r;if(4&e&&"object"==typeof r&&r&&r.__esModule)return r;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:r}),2&e&&"string"!=typeof r)for(var t in r)o.d(n,t,function(e){return r[e]}.bind(null,t));return n},o.n=function(r){var e=r&&r.__esModule?function(){return r.default}:function(){return r};return o.d(e,"a",e),e},o.o=function(r,e){return Object.prototype.hasOwnProperty.call(r,e)},o.p="",o(o.s=0)}([function(r,e,o){"use strict";var n=o(1),t=o(5);_rollbarConfig=_rollbarConfig||{},_rollbarConfig.rollbarJsUrl=_rollbarConfig.rollbarJsUrl||"https://cdn.rollbar.com/rollbarjs/refs/tags/v2.22.0/rollbar.min.js",_rollbarConfig.async=void 0===_rollbarConfig.async||_rollbarConfig.async;var a=n.setupShim(window,_rollbarConfig),l=t(_rollbarConfig);window.rollbar=n.Rollbar,a.loadFull(window,document,!_rollbarConfig.async,_rollbarConfig,l)},function(r,e,o){"use strict";var n=o(2),t=o(3);function a(r){return function(){try{return r.apply(this,arguments)}catch(r){try{console.error("[Rollbar]: Internal error",r)}catch(r){}}}}var l=0;function i(r,e){this.options=r,this._rollbarOldOnError=null;var o=l++;this.shimId=function(){return o},"undefined"!=typeof window&&window._rollbarShims&&(window._rollbarShims[o]={handler:e,messages:[]})}var s=o(4),d=function(r,e){return new i(r,e)},c=function(r){return new s(d,r)};function u(r){return a((function(){var e=this,o=Array.prototype.slice.call(arguments,0),n={shim:e,method:r,args:o,ts:new Date};window._rollbarShims[this.shimId()].messages.push(n)}))}i.prototype.loadFull=function(r,e,o,n,t){var l=!1,i=e.createElement("script"),s=e.getElementsByTagName("script")[0],d=s.parentNode;i.crossOrigin="",i.src=n.rollbarJsUrl,o||(i.async=!0),i.onload=i.onreadystatechange=a((function(){if(!(l||this.readyState&&"loaded"!==this.readyState&&"complete"!==this.readyState)){i.onload=i.onreadystatechange=null;try{d.removeChild(i)}catch(r){}l=!0,function(){var e;if(void 0===r._rollbarDidLoad){e=new Error("rollbar.js did not load");for(var o,n,a,l,i=0;o=r._rollbarShims[i++];)for(o=o.messages||[];n=o.shift();)for(a=n.args||[],i=0;i + <% /* Cookie consent banner */ %> + <% /* Google Analytics, Google Tag Manager, Snitcher etc. */ %> <% /* Global site tag (gtag.js) - Google Analytics */%> @@ -68,22 +70,6 @@ analytics.page(); }}(); - <% /* Meta pixel code */ %> - - <%/* LinkedIn insight tag*/%> - <%/* Reddit tracking pixel */%> - <% } /* Otherwise, any such scripts are excluded, and we instead inject a robots/noindex meta tag to help prevent any unwanted visits from search engines. */ @@ -187,7 +169,7 @@ Docs REST API Guides - <%= ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'Device health checks' : 'Built-in queries' %> + Built-in queries Data tables SUPPORT
    @@ -440,9 +422,6 @@ by the asset pipeline between "SCRIPTS" and "SCRIPTS END", or both. (https://sailsjs.com/docs/concepts/assets/task-automation) */ %> - <% /* Cookie consent banner */ %> - - <%/* Stripe.js */%> diff --git a/website/views/pages/articles/articles.ejs b/website/views/pages/articles/articles.ejs index 0f24be22d7..4b21814af3 100644 --- a/website/views/pages/articles/articles.ejs +++ b/website/views/pages/articles/articles.ejs @@ -1,50 +1,87 @@
    -
    -
    -
    -
    -
    All
    -
    Engineering
    -
    Security
    -
    Announcements
    -
    Guides
    -
    Success stories
    -
    Podcasts
    -
    Releases
    +
    +
    +
    +
    +
    All
    +
    Engineering
    +
    Security
    +
    Announcements
    +
    Guides
    +
    Success stories
    +
    Podcasts
    +
    Releases
    +
    +
    +
    +

    {{articleCategory}}

    +
    + +

    {{categoryDescription}}

    + Subscribe +
    +
    +
    +
    + + Article hero image + +
    +

    {{article.meta.category}}

    +
    {{article.meta.articleTitle}}
    +
    + The author's GitHub profile picture +

    {{article.meta.authorFullName}}

    +
    -
    -

    {{articleCategory}}

    -
    -

    {{categoryDescription}}

    - Subscribe -
    + -
    -
    - - Article hero image - -
    -

    {{article.meta.category}}

    -
    {{article.meta.articleTitle}}
    -
    - The author's GitHub profile picture -

    {{article.meta.authorFullName}}

    + + +
    +
    +
    +
    +
    +
    +

    Guides

    +

    Learn more about how to use Fleet to accomplish your goals.

    +
    +
    +
    +
    +
    + + search + +
    +
    + +
    - - - -
    -
    -

    No articles to display

    + +
    diff --git a/website/views/pages/articles/basic-article.ejs b/website/views/pages/articles/basic-article.ejs index bbcd597ebf..e72f85219d 100644 --- a/website/views/pages/articles/basic-article.ejs +++ b/website/views/pages/articles/basic-article.ejs @@ -1,32 +1,60 @@
    -
    -
    -

    <%=thisPage.meta.articleTitle %>

    -

    {{articleSubtitle}}

    -
    -
    -
    - - | - The author's GitHub profile picture -

    <%=thisPage.meta.authorFullName %>

    +
    +
    +
    + +
    + {{thisPage.meta.articleTitle}} +
    -
    - Subscribe - A pencil iconEdit page +
    +
    +
    +
    + + search + +
    +
    + +
    +
    +
    -
    - <%- partial(path.relative(path.dirname(__filename), path.resolve( sails.config.appPath, path.join(sails.config.builtStaticContent.compiledPagePartialsAppPath, thisPage.htmlId)))) %> -
    -
    -
    -

    Get started

    -
    - - Start now - - Talk to us +
    +
    +

    <%=thisPage.meta.articleTitle %>

    +

    {{articleSubtitle}}

    +
    +
    +
    + + | + The author's GitHub profile picture +

    <%=thisPage.meta.authorFullName %>

    +
    + +
    +
    + <%- partial(path.relative(path.dirname(__filename), path.resolve( sails.config.appPath, path.join(sails.config.builtStaticContent.compiledPagePartialsAppPath, thisPage.htmlId)))) %> +
    +
    +
    +

    Get started

    +
    + + Start now + + Talk to us +
    diff --git a/website/views/pages/contact.ejs b/website/views/pages/contact.ejs index 52bb23bf6f..d9045a69e4 100644 --- a/website/views/pages/contact.ejs +++ b/website/views/pages/contact.ejs @@ -145,17 +145,17 @@
    -
    Deputy logo
    +
    Deputy logo

    - When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy.

    - Harrison Ravazzolo + Wes Whetstone
    -

    Harrison Ravazzolo

    -

    Lead platform and identity engineer

    +

    Wes Whetstone

    +

    Staff CPE

    diff --git a/website/views/pages/device-management.ejs b/website/views/pages/device-management.ejs index f7b0f37a84..0c077596e5 100644 --- a/website/views/pages/device-management.ejs +++ b/website/views/pages/device-management.ejs @@ -4,7 +4,7 @@

    Device management (MDM)

    -

    Your easiest MDM migration

    +

    Manage everything in one place

    @@ -30,6 +30,57 @@
    + + <%/* Debunk the cross-platform myth */%> +
    +
    +
    +

    Debunk the cross-platform myth

    +

    Conventional wisdom holds that cross-platform MDM solutions don't hold up at scale. Few, if any, have lived up to the hype.

    +

    Instead, Fleet lets you work directly with data and events from each native operating system. So while you can still use familiar concepts like smart groups and extension attributes, you don’t get stuck “talking Windows” to Apple devices.

    +
    +
    + Transparency for end users +
    +
    + +
    +
    +
    +
    Operating systems
    +

    Enforce OS updates with Declarative Device Management (DDM), Nudge, and Windows Update from one console.

    +
    + +
    +
    Automated enrollment
    +

    Drop-ship devices to your end users with Apple Business Manager or Autopilot and let them set up their own account. No IT help needed.

    +
    + +
    +
    Config management
    +

    Manage settings with configuration profiles for Apple and device profiles for Windows. Use a canary team to test changes before they go live.

    +
    +
    +
    App management
    +

    Keep applications and plugins secure and up-to-date automatically. Install the software end users need or let them install it themselves via self service.

    +
    + +
    +
    Scripts and events
    +

    Easily manage and version control your custom script library. Execute shell and PowerShell scripts when computers drift from the baseline.

    +
    + +
    +
    Keep up with Apple
    +

    Fleet's team and community stay on top of the latest features and releases from all supported platform vendors—not just Apple.

    +
    +
    + +
    +
    +
    +
    +

    Head-to-head with the big players

    Considering a move to Fleet as a cross-platform, open-source MDM alternative? See how we compare:

    @@ -135,7 +186,6 @@
    -

    REST API

    checkmark
    @@ -144,12 +194,6 @@
    -
    -

    MDM migration

    -
    checkmark
    -
    -
    -

    Open source

    checkmark
    @@ -412,7 +456,7 @@
    - Compare all features + View all features
    <%/* Shorten the feedback loop section */%>
    @@ -448,45 +492,6 @@
    - - <%/* Simplify your tools section */%> -
    -
    -

    Tidy up your tools

    -

    Deploy a modern Mac-first MDM purpose-built for IT engineers and cross-training. Use principles from DevOps to manage Apple, Windows, and Linux computers declaratively.

    -
    - -
    - -
    -
    - Patch every OS -
    Patch every OS
    -

    Shorten patch timelines with custom, enforceable deadlines and grace periods for every operating system. Encourage updates with Declarative Device Management (DDM), Nudge, and Windows Update from one interface.

    -
    - -
    - Push profiles -
    Push profiles
    -

    Remote-control settings on your Macs and PCs like Wi-Fi, certificates, passwords, screen lock, etc. Deploy baselines or customize your own to comply with your organization’s security requirements.

    -
    -
    - -
    -
    - Manage software installs from anywhere -
    Manage software installs from anywhere
    -

    Get employees the software and settings they need with less drama by managing app packages, operating system versions, and patch levels for every platform, on top of open standards and data.

    -
    - -
    - Access the latest Apple and Windows APIs -
    Access the latest Apple and Windows APIs
    -

    Turn on the latest native macOS and Windows operating system capabilities like DDM and Autopilot, all in one place. Fleet’s team and community test against every new Apple and Microsoft release.

    -
    -
    -
    -
    <%/* End of page gradient */%> diff --git a/website/views/pages/docs/basic-documentation.ejs b/website/views/pages/docs/basic-documentation.ejs index cfd78bcf0a..cc8b15b912 100644 --- a/website/views/pages/docs/basic-documentation.ejs +++ b/website/views/pages/docs/basic-documentation.ejs @@ -95,24 +95,22 @@
    • - + {{page.title}} - -
      -
        +
      • Guides
      • Data tables
      • <%= ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'Device health checks' : 'Built-in queries' %>
      • Releases
      • @@ -137,23 +135,21 @@