From e4ebe3197167e71fd9578427ef5513a953338cb2 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Wed, 24 Apr 2024 08:38:07 -0300 Subject: [PATCH 01/83] use Colima for CI runners (#18495) The `macos-latest` runner is using `macos-14` + ARM now, which was causing the Docker install to fail. I switched to `macos-13` since seems to be a cheap x86_64 alternative and figured what was the problem with Colima so we don't have to deal with Docker anymore. --- .github/workflows/test-packaging.yml | 39 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test-packaging.yml b/.github/workflows/test-packaging.yml index 428544fcb0..84cb2cb5f8 100644 --- a/.github/workflows/test-packaging.yml +++ b/.github/workflows/test-packaging.yml @@ -42,38 +42,37 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + # note: in order to test both the wix and the docker flow for msi + # packages, this worker needs to run on an x86_64 architecture. + # `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: - # Docker needs to be installed manually on macOS. - # From https://github.com/docker/for-mac/issues/2359#issuecomment-943131345 - # FIXME: lock Docker version to 4.10.0 as newer versions fail to initialize - name: Harden Runner uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 with: egress-policy: audit - - name: Install Docker - timeout-minutes: 20 - if: matrix.os == 'macos-latest' - run: | - curl -L https://raw.githubusercontent.com/Homebrew/homebrew-cask/c65030146a5cf2070c2499b6c68e2c3495c99731/Casks/docker.rb > docker.rb - brew install --cask docker.rb - sudo /Applications/Docker.app/Contents/MacOS/Docker --unattended --install-privileged-components - open -a /Applications/Docker.app --args --unattended --accept-license - echo "Waiting for Docker to start up..." - while ! /Applications/Docker.app/Contents/Resources/bin/docker info &>/dev/null; do - sleep 1; - done - echo "Docker is ready." - - 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: 5 + # 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: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: @@ -83,7 +82,7 @@ jobs: uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Install wine and wix - if: matrix.os == 'macos-latest' + 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 @@ -124,5 +123,5 @@ jobs: run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop - name: Build MSI (using local Wix) - if: matrix.os == 'macos-latest' + 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 From 49d270ba2cc3c0693056f95d19f5f4aa3ae587d9 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 24 Apr 2024 10:46:23 -0500 Subject: [PATCH 02/83] Website: Update email footer (#18504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: https://github.com/fleetdm/confidential/issues/6254 Changes: - Updated the footer in the website's email template (Fleet Device Management Inc. » Fleet Inc.) --- website/views/layouts/layout-email.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/layouts/layout-email.ejs b/website/views/layouts/layout-email.ejs index d79fb6aa6d..1895bcca1c 100644 --- a/website/views/layouts/layout-email.ejs +++ b/website/views/layouts/layout-email.ejs @@ -13,7 +13,7 @@ Join the osquery Slack community
-

© <%= (new Date()).getFullYear() %> Fleet Device Management Inc.
All trademarks, service marks, and company names are the property of their respective owners.

+

© <%= (new Date()).getFullYear() %> Fleet Inc.
All trademarks, service marks, and company names are the property of their respective owners.

From 42171fcbbb2300026ac0d4fa64d0b6d1c3d7f495 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Wed, 24 Apr 2024 13:17:47 -0500 Subject: [PATCH 03/83] typofix (#18505) Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com> --- website/views/pages/support.ejs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/views/pages/support.ejs b/website/views/pages/support.ejs index 8a1d76702e..2329e114e0 100644 --- a/website/views/pages/support.ejs +++ b/website/views/pages/support.ejs @@ -13,10 +13,10 @@ Osquery logo

Osquery Slack

-

<%= primaryBuyingSituation === 'eo-it' ? 'Chat about IT visibility, secripting, access, and identity.' : - primaryBuyingSituation === 'eo-security' ? 'Chat securing servers, laptops, and more.' : +

<%= primaryBuyingSituation === 'eo-it' ? 'Chat about IT visibility, scripting, access, and identity.' : + primaryBuyingSituation === 'eo-security' ? 'Chat about securing servers, laptops, and more.' : primaryBuyingSituation === 'vm' ? 'Chat about the future of vulnerability management, OT/ICS, cybersecurity, and more.' : - 'Chat about using Fleet to secure servers, laptops, and more' + 'Chat about using Fleet to secure servers, laptops, and more.' %>

From b047e39af780fe3d3b3c935362ce564c7ce0b0af Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Wed, 24 Apr 2024 13:45:35 -0500 Subject: [PATCH 04/83] Adding an announce only flag so we can announce after publishing content (#18500) --- tools/release/README.md | 2 +- tools/release/publish_release.sh | 121 +++++++++++++++++-------------- 2 files changed, 68 insertions(+), 55 deletions(-) diff --git a/tools/release/README.md b/tools/release/README.md index 08aed8a25d..0fe38b990a 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -39,7 +39,7 @@ example # Tag main ./tools/release/publish_release.sh -ag # Publish main -./tools/release/publish_release.sh -au +./tools/release/publish_release.sh -auq # Go update osquery-slack version ``` diff --git a/tools/release/publish_release.sh b/tools/release/publish_release.sh index 059b1595d0..313774ab15 100755 --- a/tools/release/publish_release.sh +++ b/tools/release/publish_release.sh @@ -76,7 +76,8 @@ usage() { echo " -f, --force Skip all confirmations" echo " -h, --help Display this help message and exit" echo " -g, --tag Run the tag step" - echo " -m, --minor Increment to a minor version instead of patch (Required if including non-bugs" + echo " -m, --minor Increment to a minor version instead of patch (Required if including non-bugs)" + echo " -n, --announce_only Announce the release only, do not publish the release." echo " -o, --open_api_key Set the Open API key for calling out to ChatGPT" echo " -p, --print If the release is already drafted then print out the helpful info" echo " -q, --quiet This will skip notifying in slack" @@ -196,7 +197,7 @@ build_changelog() { prompt=$'I am creating a changelog for an open source project from a list of commit messages. Please format it for me using the following rules:\n1. Correct spelling and punctuation.\n2. Sentence casing.\n3. Past tense.\n4. Each list item is designated with an asterisk.\n5. Output in markdown format.' if [[ "$main_release" == "true" ]]; then # Place to make a main targeted prompt - prompt=$'I am creating a changelog for an open source project from a list of commit messages. Please format it for me using the following rules:\n1. Correct spelling and punctuation.\n2. Sentence casing.\n3. Past tense.\n4. Each list item is designated with an asterisk.\n5. Output in markdown format.' + prompt=$'I am creating a changelog for an open source project from a list of commit messages. Please format it for me using the following rules: Organize updates into three categories: Endpoint Operations, Device Management (MDM), and Vulnerability Management, with all bug fixes and misc. improvements listed under "Bug fixes and improvements". Start each entry with a past tense verb, using hyphens for bullet points. Include specific details for new features, bug fixes, API changes, and any necessary user actions. Note changes in user interfaces, system feedback, and significant architectural updates. Highlight mandatory actions and major impacts, especially for system administrators. Order seemingly important features at the top of their respective lists.' fi content=$(cat new_changelog | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g') @@ -325,6 +326,44 @@ print_announce_info() { fi } +general_announce_info() { + if [[ "$main_release" == "true" ]]; then + article_url="https://fleetdm.com/releases/fleet-$target_milestone" + article_published=`curl -is "$article_url" | head -n 1 | awk '{print $2}'` + if [[ "$article_published" != "200" ]]; then + echo "Could't find article at '$article_url'" + exit 1 + fi + + # TODO Publish Linkedin post about release article here and save url + linkedin_post_url="" + fi + echo "=========================================================================" + echo "Update osquery Slack Fleet channel topic to say the correct version $next_ver" + echo "=========================================================================" + # Slack + slack_hook_url=https://hooks.slack.com/services + app_id=T019PP37ALW + announce_text=":cloud: :rocket: The latest version of Fleet is $target_milestone.\nMore info: https://github.com/fleetdm/fleet/releases/tag/$next_tag" + if [[ "$main_release" == "true" ]]; then + announce_text=":cloud: :rocket: The latest version of Fleet is $target_milestone.\nMore info: https://github.com/fleetdm/fleet/releases/tag/$next_tag\nRelease article: $article_url\nLinkedIn post: $linkedin_post_url" + fi + + echo -e $announce_text + + if [ "$quiet" = "false" ]; then + if [ "$dry_run" = "false" ]; then + curl -X POST -H 'Content-type: application/json' \ + --data "{\"text\":\"$announce_text\"}" \ + $slack_hook_url/$app_id/$SLACK_GENERAL_TOKEN + + curl -X POST -H 'Content -type: application/json' \ + --data "{\"text\":\"$announce_text\nDogfood Deployed $dogfood_deploy\"}" \ + $slack_hook_url/$app_id/$SLACK_HELP_INFRA_TOKEN + fi + fi +} + update_release_notes() { if [ "$dry_run" = "false" ]; then if [ ! -f temp_changelog ]; then @@ -412,62 +451,34 @@ tag() { publish() { if [ "$dry_run" = "false" ]; then - if [[ "$main_release" == "true" ]]; then - article_url="https://fleetdm.com/releases/fleet-$target_milestone" - article_published=`curl -is "$article_url" | head -n 1 | awk '{print $2}'` - if [[ "$article_published" != "200" ]]; then - echo "Coulndn't find article at '$article_url'" - exit 1 - fi + if [ "$announce_only" = "false" ]; then + # TODO more checks to validate we are ready to publish + gh release edit --draft=false --latest $next_tag + gh workflow run dogfood-deploy.yml -f DOCKER_IMAGE=fleetdm/fleet:$next_ver + show_spinner 200 + dogfood_deploy=`gh run list --workflow=dogfood-deploy.yml --status in_progress -L 1 --json url | jq -r '.[] | .url'` + cd tools/fleetctl-npm && npm publish - # TODO Publish Linkedin post about release article here and save url - linkedin_post_url="" - fi - # TODO more checks to validate we are ready to publish - gh release edit --draft=false --latest $next_tag - gh workflow run dogfood-deploy.yml -f DOCKER_IMAGE=fleetdm/fleet:$next_ver - show_spinner 200 - echo "=========================================================================" - echo "Update osquery Slack Fleet channel topic to say the correct version $next_ver" - echo "=========================================================================" - dogfood_deploy=`gh run list --workflow=dogfood-deploy.yml --status in_progress -L 1 --json url | jq -r '.[] | .url'` - cd tools/fleetctl-npm && npm publish + issues=`gh issue list -m $target_milestone --json number | jq -r '.[] | .number'` + for iss in $issues; do + is_story=`gh issue view $iss --json labels | jq -r '.labels | .[] | .name' | grep story` + # close all non-stories + if [[ "$is_story" == "" ]]; then + echo "Closing #$iss" + gh issue close $iss + fi + done - issues=`gh issue list -m $target_milestone --json number | jq -r '.[] | .number'` - for iss in $issues; do - is_story=`gh issue view $iss --json labels | jq -r '.labels | .[] | .name' | grep story` - # close all non-stories - if [[ "$is_story" == "" ]]; then - echo "Closing #$iss" - gh issue close $iss - fi - done - - echo "Closing milestone" - gh api repos/fleetdm/fleet/milestones/$target_milestone_number -f state=closed - - # Slack - slack_hook_url=https://hooks.slack.com/services - app_id=T019PP37ALW - announce_text=":cloud: :rocket: The latest version of Fleet is $target_milestone.\nMore info: https://github.com/fleetdm/fleet/releases/tag/$next_tag\nUpgrade now: https://fleetdm.com/docs/deploying/upgrading-fleet" - if [[ "$main_release" == "true" ]]; then - announce_text=":cloud: :rocket: The latest version of Fleet is $target_milestone.\nMore info: https://github.com/fleetdm/fleet/releases/tag/$next_tag\nUpgrade now: https://fleetdm.com/docs/deploying/upgrading-fleet\nRelease Article: $article_url\nLinkedIn Post: $linkedin_post_url" - fi - - echo $announce_text - - if [ "$quiet" = "false" ]; then - curl -X POST -H 'Content-type: application/json' \ - --data "{\"text\":\"$announce_text\"}" \ - $slack_hook_url/$app_id/$SLACK_GENERAL_TOKEN - - curl -X POST -H 'Content-type: application/json' \ - --data "{\"text\":\"$announce_text\nDogfood Deployed $dogfood_deploy\"}" \ - $slack_hook_url/$app_id/$SLACK_HELP_INFRA_TOKEN + echo "Closing milestone" + gh api repos/fleetdm/fleet/milestones/$target_milestone_number -f state=closed fi else echo "DRYRUN: Would have published $next_tag / deployed to dogfood / closed non-stories / closed milestone / announced in slack" fi + + echo "Send general announce" + # Send general announcement in #general + general_announce_info } # Validate we have all commands required to perform this script @@ -478,6 +489,7 @@ cherry_pick_resolved=false dry_run=false force=false minor=false +announce_only=false open_api_key="" start_version="" target_date="" @@ -499,6 +511,7 @@ for arg in "$@"; do "--force") set -- "$@" "-f" ;; "--help") set -- "$@" "-h" ;; "--minor") set -- "$@" "-m" ;; + "--announce_only") set -- "$@" "-n" ;; "--open_api_key") set -- "$@" "-o" ;; "--print") set -- "$@" "-p" ;; "--quiet") set -- "$@" "-q" ;; @@ -513,7 +526,7 @@ for arg in "$@"; do done # Extract options and their arguments using getopts -while getopts "acdfhgmo:pqrs:t:uv:" opt; do +while getopts "acdfhgmno:pqrs:t:uv:" opt; do case "$opt" in a) main_release=true ;; c) cherry_pick_resolved=true ;; @@ -522,6 +535,7 @@ while getopts "acdfhgmo:pqrs:t:uv:" opt; do h) usage; exit 0 ;; g) do_tag=true ;; m) minor=true ;; + n) announce_only=true ;; o) open_api_key=$OPTARG ;; p) print_info=true ;; q) quiet=true ;; @@ -664,7 +678,6 @@ if [[ "$target_milestone_number" == "" ]]; then fi echo "Found milestone $target_milestone with number $target_milestone_number" - if [ "$print_info" = "true" ]; then print_announce_info exit 0 From 56a26f5ef80df3ce0cf3461bf80b1cf168759905 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Wed, 24 Apr 2024 14:12:53 -0500 Subject: [PATCH 05/83] Add George to infra codeowners (#18519) --- CODEOWNERS | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b1a4d0f957..81aa6a12bf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,9 +54,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 -/charts/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster -/terraform/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster +/infrastructure/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv +/charts/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv +/terraform/ @rfairburn @ksatter @lukeheath @edwardsb @pacamaster @georgekarrv /it-and-security/ @noahtalerman @lukeheath ############################################################################################## From 2a1c19e26cc1eb491b7daafbcb289823806a8e5c Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Wed, 24 Apr 2024 14:13:59 -0500 Subject: [PATCH 06/83] Adding changes for Fleet v4.49.0 (#18496) --- CHANGELOG.md | 51 +++++++++++++++++++ changes/12290-run-query-on-host | 1 - changes/12292-policies-filter-by-platform | 1 - changes/15565-windows-automatic-enrollment | 1 - ...20-add-windows-mdm-support-to-osquery-perf | 2 - changes/16205-health-failing-counts | 1 - changes/16260-recategorize-mdm-api-endpoints | 1 - changes/16345-disabled-checkbox-tooltip | 1 - changes/16500-policy-pass-fail-percentage | 1 - changes/16562-sql-deadlock | 1 - changes/16562-sql-deadlock copy | 1 - changes/16661-current-instance-checks | 1 - changes/16767-updating-host-labels | 1 - changes/16817-ms-edge-vuln | 1 - ...1-improve-carve-request-timeout-error-code | 1 - changes/17003-ingest-vscode_extensions | 1 - changes/17018-reset-query-report | 1 - changes/17061-homebrew-python | 1 - changes/17065-null-smtp_settings | 1 - changes/17208-hover-states | 1 - changes/17230-fleet-in-your-calendar | 5 -- changes/17264-batch-process-gitops | 1 - changes/17265-filter-alignment | 1 - changes/17288-fix-sort-of-sql-results | 1 - changes/17308-script-content-cleanup | 3 -- ...313-add-env-from-secret-capability-to-helm | 1 - changes/17347-team-user-os-version-restrict | 1 - changes/17361-host-details-updates | 1 - changes/17362-orbit-and-desktop-version | 1 - .../17401-add-enable-release-device-manually | 2 - changes/17404-mdm-custom-settings | 1 - changes/17418-macos-14-nudge | 1 - changes/17420-update-ddm-profile-os-updates | 1 - .../17534-improve-error-states-org-settings | 2 - changes/17557-ui-mdm-off-tooltip | 1 - changes/17559-batch-set-duplicate-mdm | 1 - changes/17562-windows-server-2019-os-details | 1 - changes/17563-windows-add | 1 - changes/17621-bulk-delete-hosts-all-teams | 1 - changes/17624-modal-flash-message-error | 1 - ...-render-standard-query-platforms-correctly | 1 - changes/17692-enrollment-state-3.md | 1 - changes/17733-innodb-lock-waits | 1 - changes/17787-hidden-columns | 1 - changes/17897-api-resend-mdm-profile | 1 - changes/17899-add-manual-labels-api | 1 - ...ix-styling-for-live-query-disabled-warning | 1 - changes/17946-fleetd-chrome-numbers | 2 - changes/18060-host-activity-styling-bugs | 1 - changes/18065-calendar-config-panic | 1 - .../18081-upload-apple-profile-error-message | 1 - ...83-no-values-in-host-details-query-reports | 1 - changes/18084-hdp-empty-state-padding | 1 - changes/18126-steam-vulns | 3 -- ...2-fix-migration-issue-related-to-collation | 1 - ...x-release-device-with-pending-ddm-profiles | 1 - changes/18256-calendar-feature-url-validation | 1 - changes/18276-fix-schema-button-location | 1 - changes/18299-gitops-calendar-validation | 1 - changes/18350-calendar-event-for-invalid-sql | 1 - changes/issue-17409-add-ddm-activities-to-ui | 1 - changes/issue-17416-update-ui-to-support-ddm | 1 - changes/issue-17417-ui-os-updates-ddm | 1 - changes/issue-17476-get-bitlocker-status | 2 - changes/issue-17896-ui-resend-profile | 1 - changes/issue-17898-new-manual-lables | 1 - changes/issue-18082-os-settings-stylings | 2 - .../issue-18483-fix-download-enroll-profile | 1 - changes/license-comparison | 1 - changes/min-node-version | 1 - charts/fleet/Chart.yaml | 2 +- charts/fleet/values.yaml | 2 +- .../dogfood/terraform/aws/variables.tf | 2 +- .../dogfood/terraform/gcp/variables.tf | 2 +- terraform/README.md | 2 +- terraform/byo-vpc/README.md | 2 +- terraform/byo-vpc/byo-db/byo-ecs/variables.tf | 2 +- terraform/byo-vpc/byo-db/variables.tf | 2 +- terraform/byo-vpc/example/main.tf | 2 +- terraform/byo-vpc/variables.tf | 2 +- terraform/example/main.tf | 4 +- terraform/variables.tf | 2 +- tools/fleetctl-npm/package.json | 2 +- 83 files changed, 65 insertions(+), 97 deletions(-) delete mode 100644 changes/12290-run-query-on-host delete mode 100644 changes/12292-policies-filter-by-platform delete mode 100644 changes/15565-windows-automatic-enrollment delete mode 100644 changes/16120-add-windows-mdm-support-to-osquery-perf delete mode 100644 changes/16205-health-failing-counts delete mode 100644 changes/16260-recategorize-mdm-api-endpoints delete mode 100644 changes/16345-disabled-checkbox-tooltip delete mode 100644 changes/16500-policy-pass-fail-percentage delete mode 100644 changes/16562-sql-deadlock delete mode 100644 changes/16562-sql-deadlock copy delete mode 100644 changes/16661-current-instance-checks delete mode 100644 changes/16767-updating-host-labels delete mode 100644 changes/16817-ms-edge-vuln delete mode 100644 changes/16951-improve-carve-request-timeout-error-code delete mode 100644 changes/17003-ingest-vscode_extensions delete mode 100644 changes/17018-reset-query-report delete mode 100644 changes/17061-homebrew-python delete mode 100644 changes/17065-null-smtp_settings delete mode 100644 changes/17208-hover-states delete mode 100644 changes/17230-fleet-in-your-calendar delete mode 100644 changes/17264-batch-process-gitops delete mode 100644 changes/17265-filter-alignment delete mode 100644 changes/17288-fix-sort-of-sql-results delete mode 100644 changes/17308-script-content-cleanup delete mode 100644 changes/17313-add-env-from-secret-capability-to-helm delete mode 100644 changes/17347-team-user-os-version-restrict delete mode 100644 changes/17361-host-details-updates delete mode 100644 changes/17362-orbit-and-desktop-version delete mode 100644 changes/17401-add-enable-release-device-manually delete mode 100644 changes/17404-mdm-custom-settings delete mode 100644 changes/17418-macos-14-nudge delete mode 100644 changes/17420-update-ddm-profile-os-updates delete mode 100644 changes/17534-improve-error-states-org-settings delete mode 100644 changes/17557-ui-mdm-off-tooltip delete mode 100644 changes/17559-batch-set-duplicate-mdm delete mode 100644 changes/17562-windows-server-2019-os-details delete mode 100644 changes/17563-windows-add delete mode 100644 changes/17621-bulk-delete-hosts-all-teams delete mode 100644 changes/17624-modal-flash-message-error delete mode 100644 changes/17662-render-standard-query-platforms-correctly delete mode 100644 changes/17692-enrollment-state-3.md delete mode 100644 changes/17733-innodb-lock-waits delete mode 100644 changes/17787-hidden-columns delete mode 100644 changes/17897-api-resend-mdm-profile delete mode 100644 changes/17899-add-manual-labels-api delete mode 100644 changes/17927-fix-styling-for-live-query-disabled-warning delete mode 100644 changes/17946-fleetd-chrome-numbers delete mode 100644 changes/18060-host-activity-styling-bugs delete mode 100644 changes/18065-calendar-config-panic delete mode 100644 changes/18081-upload-apple-profile-error-message delete mode 100644 changes/18083-no-values-in-host-details-query-reports delete mode 100644 changes/18084-hdp-empty-state-padding delete mode 100644 changes/18126-steam-vulns delete mode 100644 changes/18142-fix-migration-issue-related-to-collation delete mode 100644 changes/18160-fix-release-device-with-pending-ddm-profiles delete mode 100644 changes/18256-calendar-feature-url-validation delete mode 100644 changes/18276-fix-schema-button-location delete mode 100644 changes/18299-gitops-calendar-validation delete mode 100644 changes/18350-calendar-event-for-invalid-sql delete mode 100644 changes/issue-17409-add-ddm-activities-to-ui delete mode 100644 changes/issue-17416-update-ui-to-support-ddm delete mode 100644 changes/issue-17417-ui-os-updates-ddm delete mode 100644 changes/issue-17476-get-bitlocker-status delete mode 100644 changes/issue-17896-ui-resend-profile delete mode 100644 changes/issue-17898-new-manual-lables delete mode 100644 changes/issue-18082-os-settings-stylings delete mode 100644 changes/issue-18483-fix-download-enroll-profile delete mode 100644 changes/license-comparison delete mode 100644 changes/min-node-version diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad5bc1acb..0717507c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +## Fleet 4.49.0 (Apr 24, 2024) + +### Endpoint operations + +- Added integration with Google Calendar for policy compliance events. +- Added new API endpoints to add/remove manual labels to/from a host. +- Updated the `POST /api/v1/fleet/labels` and `PATCH /api/v1/fleet/labels/{id}` endpoints to support creation and update of manual labels. +- Implemented changes in `fleetctl gitops` for batch processing queries and policies. +- Enabled setting host status webhook at the team level via REST API and fleetctl apply/gitops. + +### Device management (MDM) + +- Added API functionality for creating DDM declarations, both individually and as a batch. +- Added creation or update of macOS DDM profile to enforce OS Updates settings whenever the settings are changed. +- Updated `fleetctl run-script` to include new `--team` and `--script-name` flags. +- Displayed disk encryption status in macOS as "verifying" while verifying the escrowed key. +- Added the `enable_release_device_manually` configuration setting for teams and no team, which controls the automatic release of a macOS DEP-enrolled device. +- Updated the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host. +- Added the `enable_release_device_manually` configuration, which affects macOS automatic enrollment profile settings. + +### Vulnerability management + +- Ignored Valve Corporation's Steam client's vulnerabilities on Windows and macOS due to retrieval challenges of the true version. +- Updated the GET fleet/os_versions and GET fleet/os_versions/[id] to restrict team users from accessing os versions on hosts from other teams. + +### Bug fixes and improvements + +- Upgraded Golang version to 1.21.7. +- Added a minimum supported node version in the `package.json`. +- Made block_id mismatch errors more informative as 400s instead of 500s. +- Added Windows MDM support to the `osquery-perf` host-simulation command. +- Updated calendar events automations to not show error validation on enabling the feature. +- Migrated MDM-related endpoints to new paths while maintaining support for old endpoints indefinitely. +- Added a missing database index to the MDM Windows enrollments table to improve performance at scale. +- Added cross-platform check for duplicate MDM profiles names in batch set MDM profiles API. +- Fixed a bug where Microsoft Edge was not reporting vulnerabilities. +- Fixed an issue with the `20240327115617_CreateTableNanoDDMRequests` database migration. +- Fixed the error message to indicate if a conflict on uploading an Apple profile was caused by the profile's name or its identifier. +- Fixed license checks to allow migration and restoring DEP devices during trial. +- Fixed a 500 error in MySQL 8 and when DB user has insufficient privileges for `fleetctl debug db-locks` and `fleetctl debug db-innodb-status`. +- Fixed a bug where values not derived from "actual" fleetd-chrome tables were not being displayed correctly. +- Fixed a bug where values were not being rendered in host-specific query reports. +- Fixed an issue with automatic release of the device after setup when a DDM profile is pending. +- Fixed UI issues: alignment bugs, padding around empty states, tooltip rendering, and incorrect rendering of the global Host status expiry settings page. +- Fixed a bug where `null` or excluded `smtp_settings` caused a UI 500 error. +- Fixed an issue where a bad request response from a 3rd party MDM solution would result in a 500 error in Fleet during MDM migration. +- Fixed a bug where updating policy name could result in multiple policies with the same name in a team. +- Fixed potential server panic when events are created with calendar integration, but then global calendar integration is disabled. +- Fixed fleetctl gitops dry-run validation issues when enabling calendar integration for the first time. +- Fixed a bug where all Windows MDM enrollments were detected as automatic. + ## Fleet 4.48.3 (Apr 16, 2024) ### Bug fixes diff --git a/changes/12290-run-query-on-host b/changes/12290-run-query-on-host deleted file mode 100644 index a2459e28e0..0000000000 --- a/changes/12290-run-query-on-host +++ /dev/null @@ -1 +0,0 @@ -- UI revamp: Run query on an online host diff --git a/changes/12292-policies-filter-by-platform b/changes/12292-policies-filter-by-platform deleted file mode 100644 index dbc31fab33..0000000000 --- a/changes/12292-policies-filter-by-platform +++ /dev/null @@ -1 +0,0 @@ -* Add filters by platform to select a new policy modal \ No newline at end of file diff --git a/changes/15565-windows-automatic-enrollment b/changes/15565-windows-automatic-enrollment deleted file mode 100644 index a89e709468..0000000000 --- a/changes/15565-windows-automatic-enrollment +++ /dev/null @@ -1 +0,0 @@ -- Fix a bug where all Windows MDM enrollments were detected as automatic diff --git a/changes/16120-add-windows-mdm-support-to-osquery-perf b/changes/16120-add-windows-mdm-support-to-osquery-perf deleted file mode 100644 index a8ebd32ce7..0000000000 --- a/changes/16120-add-windows-mdm-support-to-osquery-perf +++ /dev/null @@ -1,2 +0,0 @@ -* Added Windows MDM support to the `osquery-perf` host-simulation command. -* Added a missing database index to the MDM Windows enrollments table that will improve performance at scale. diff --git a/changes/16205-health-failing-counts b/changes/16205-health-failing-counts deleted file mode 100644 index df792a3fa6..0000000000 --- a/changes/16205-health-failing-counts +++ /dev/null @@ -1 +0,0 @@ -- The Host Health API now includes failing policy counts \ No newline at end of file diff --git a/changes/16260-recategorize-mdm-api-endpoints b/changes/16260-recategorize-mdm-api-endpoints deleted file mode 100644 index cdc03d0933..0000000000 --- a/changes/16260-recategorize-mdm-api-endpoints +++ /dev/null @@ -1 +0,0 @@ -* Migrate MDM-related endpoints to new paths, deprecating (but still supporting indefinitely) the old endpoints. diff --git a/changes/16345-disabled-checkbox-tooltip b/changes/16345-disabled-checkbox-tooltip deleted file mode 100644 index 5e83ded1e1..0000000000 --- a/changes/16345-disabled-checkbox-tooltip +++ /dev/null @@ -1 +0,0 @@ -- UI fix: users can see a tooltip on a disabled checkbox diff --git a/changes/16500-policy-pass-fail-percentage b/changes/16500-policy-pass-fail-percentage deleted file mode 100644 index bc93d8227f..0000000000 --- a/changes/16500-policy-pass-fail-percentage +++ /dev/null @@ -1 +0,0 @@ -* When a live policy run finishes, display the percentages of passing and failing hosts to the user. diff --git a/changes/16562-sql-deadlock b/changes/16562-sql-deadlock deleted file mode 100644 index c4c725e435..0000000000 --- a/changes/16562-sql-deadlock +++ /dev/null @@ -1 +0,0 @@ -Reduced the number of 'Deadlock found' errors seen by the server when multiple hosts share the same UUID diff --git a/changes/16562-sql-deadlock copy b/changes/16562-sql-deadlock copy deleted file mode 100644 index c4c725e435..0000000000 --- a/changes/16562-sql-deadlock copy +++ /dev/null @@ -1 +0,0 @@ -Reduced the number of 'Deadlock found' errors seen by the server when multiple hosts share the same UUID diff --git a/changes/16661-current-instance-checks b/changes/16661-current-instance-checks deleted file mode 100644 index 9d03a9ca3a..0000000000 --- a/changes/16661-current-instance-checks +++ /dev/null @@ -1 +0,0 @@ -vulnerabilities.current_instance_checks=no is now an alias for vulnerabilities.disable_schedule=true diff --git a/changes/16767-updating-host-labels b/changes/16767-updating-host-labels deleted file mode 100644 index 32c1e635cc..0000000000 --- a/changes/16767-updating-host-labels +++ /dev/null @@ -1 +0,0 @@ -* Added endpoints to add/remove manual labels to/from a host. `POST /api/v1/fleet/hosts/:id/labels` and `DELETE /api/v1/fleet/hosts/:id/labels`. diff --git a/changes/16817-ms-edge-vuln b/changes/16817-ms-edge-vuln deleted file mode 100644 index 56b7664368..0000000000 --- a/changes/16817-ms-edge-vuln +++ /dev/null @@ -1 +0,0 @@ -- Fixed issue where microsoft edge was not reporting vulnerabilities \ No newline at end of file diff --git a/changes/16951-improve-carve-request-timeout-error-code b/changes/16951-improve-carve-request-timeout-error-code deleted file mode 100644 index c23c1bb466..0000000000 --- a/changes/16951-improve-carve-request-timeout-error-code +++ /dev/null @@ -1 +0,0 @@ -* Made block_id mismatch errors more informative as 400s instead of 500s. diff --git a/changes/17003-ingest-vscode_extensions b/changes/17003-ingest-vscode_extensions deleted file mode 100644 index a8ffcdf7ef..0000000000 --- a/changes/17003-ingest-vscode_extensions +++ /dev/null @@ -1 +0,0 @@ -* Visual Studio extensions added to Fleet's software inventory. diff --git a/changes/17018-reset-query-report b/changes/17018-reset-query-report deleted file mode 100644 index 444fac3f8b..0000000000 --- a/changes/17018-reset-query-report +++ /dev/null @@ -1 +0,0 @@ -- Query report is reset when there is a change to the selected platform or selected minimum osquery version diff --git a/changes/17061-homebrew-python b/changes/17061-homebrew-python deleted file mode 100644 index bf76e59e02..0000000000 --- a/changes/17061-homebrew-python +++ /dev/null @@ -1 +0,0 @@ -Fixing false negative vulnerabilities on macOS Homebrew python packages. diff --git a/changes/17065-null-smtp_settings b/changes/17065-null-smtp_settings deleted file mode 100644 index b37de25553..0000000000 --- a/changes/17065-null-smtp_settings +++ /dev/null @@ -1 +0,0 @@ -- Fix a bug where `null` or excluded `smtp_settings` caused a UI 500. diff --git a/changes/17208-hover-states b/changes/17208-hover-states deleted file mode 100644 index 5ae0c7f17a..0000000000 --- a/changes/17208-hover-states +++ /dev/null @@ -1 +0,0 @@ -Fleet UI: Add hover states to clickable elements diff --git a/changes/17230-fleet-in-your-calendar b/changes/17230-fleet-in-your-calendar deleted file mode 100644 index 299239a074..0000000000 --- a/changes/17230-fleet-in-your-calendar +++ /dev/null @@ -1,5 +0,0 @@ -Added integration with Google Calendar. -- Fleet admins can enable Google Calendar integration by using a Google service account with domain-wide delegation. -- Calendar integration is enabled at the team level for specific team policies. -- If the policy is failing, a calendar event will be put on the host user's calendar for the 3rd Tuesday of the month. -- During the event, Fleet will fire a webhook. IT admins should use this webhook to trigger a script or MDM command that will remediate the issue. diff --git a/changes/17264-batch-process-gitops b/changes/17264-batch-process-gitops deleted file mode 100644 index cfa7ce9776..0000000000 --- a/changes/17264-batch-process-gitops +++ /dev/null @@ -1 +0,0 @@ -- `fleetctl gitops` now batch processes queries and policies \ No newline at end of file diff --git a/changes/17265-filter-alignment b/changes/17265-filter-alignment deleted file mode 100644 index a27c775810..0000000000 --- a/changes/17265-filter-alignment +++ /dev/null @@ -1 +0,0 @@ -* Fix a small alignment bug diff --git a/changes/17288-fix-sort-of-sql-results b/changes/17288-fix-sort-of-sql-results deleted file mode 100644 index ededd089b4..0000000000 --- a/changes/17288-fix-sort-of-sql-results +++ /dev/null @@ -1 +0,0 @@ -* UI fix of sql result sort for both string and numerical columns on live query results, live policy results, and query report \ No newline at end of file diff --git a/changes/17308-script-content-cleanup b/changes/17308-script-content-cleanup deleted file mode 100644 index c51a6933e3..0000000000 --- a/changes/17308-script-content-cleanup +++ /dev/null @@ -1,3 +0,0 @@ -- Adds a migration that removes the `script_contents` columns that aren't needed anymore due to the - introduction of the `script_contents` table -- Adds a cleanup cron job that will remove unused script contents periodically \ No newline at end of file diff --git a/changes/17313-add-env-from-secret-capability-to-helm b/changes/17313-add-env-from-secret-capability-to-helm deleted file mode 100644 index 9f052ff03a..0000000000 --- a/changes/17313-add-env-from-secret-capability-to-helm +++ /dev/null @@ -1 +0,0 @@ -- add env from secret/cm capability to helm charts \ No newline at end of file diff --git a/changes/17347-team-user-os-version-restrict b/changes/17347-team-user-os-version-restrict deleted file mode 100644 index 49d0bb6a6e..0000000000 --- a/changes/17347-team-user-os-version-restrict +++ /dev/null @@ -1 +0,0 @@ -For GET fleet/os_versions and GET fleet/os_versions/[id], team users no longer have access to os versions on hosts from other teams. diff --git a/changes/17361-host-details-updates b/changes/17361-host-details-updates deleted file mode 100644 index 7f3235f2af..0000000000 --- a/changes/17361-host-details-updates +++ /dev/null @@ -1 +0,0 @@ -- UI: Surface fleet desktop and orbit version to the host details page diff --git a/changes/17362-orbit-and-desktop-version b/changes/17362-orbit-and-desktop-version deleted file mode 100644 index c681b7644e..0000000000 --- a/changes/17362-orbit-and-desktop-version +++ /dev/null @@ -1 +0,0 @@ -In GET fleet/hosts/:id response, added orbit_version, fleet_desktop_version, and scripts_enabled fields. diff --git a/changes/17401-add-enable-release-device-manually b/changes/17401-add-enable-release-device-manually deleted file mode 100644 index 4fcda2283c..0000000000 --- a/changes/17401-add-enable-release-device-manually +++ /dev/null @@ -1,2 +0,0 @@ -* Added the `enable_release_device_manually` configuration setting for a team and no team. **Note** that the macOS automatic enrollment profile cannot set the `await_device_configured` option anymore, this setting is controlled by Fleet via the new `enable_release_device_manually` option. -* Automatically release a macOS DEP-enrolled device after enrollment commands and profiles have been delivered, unless `enable_release_device_manually` is set to `true`. diff --git a/changes/17404-mdm-custom-settings b/changes/17404-mdm-custom-settings deleted file mode 100644 index 78b0506bd0..0000000000 --- a/changes/17404-mdm-custom-settings +++ /dev/null @@ -1 +0,0 @@ -- Adds API functionality for creating DDM declarations, both individually and as a batch. \ No newline at end of file diff --git a/changes/17418-macos-14-nudge b/changes/17418-macos-14-nudge deleted file mode 100644 index cdf29816b9..0000000000 --- a/changes/17418-macos-14-nudge +++ /dev/null @@ -1 +0,0 @@ -* macOS 14 and higher no longer display nudge notifications diff --git a/changes/17420-update-ddm-profile-os-updates b/changes/17420-update-ddm-profile-os-updates deleted file mode 100644 index 54188ff7a2..0000000000 --- a/changes/17420-update-ddm-profile-os-updates +++ /dev/null @@ -1 +0,0 @@ -* Added creation or update of macOS DDM profile to enforce OS Updates settings whenever the settings are changed. diff --git a/changes/17534-improve-error-states-org-settings b/changes/17534-improve-error-states-org-settings deleted file mode 100644 index 6fdff36d01..0000000000 --- a/changes/17534-improve-error-states-org-settings +++ /dev/null @@ -1,2 +0,0 @@ -- Fix error state rendering on the global Host status expiry settings page, fix error state - alignment for tooltip-wrapper field labels across organization settings. diff --git a/changes/17557-ui-mdm-off-tooltip b/changes/17557-ui-mdm-off-tooltip deleted file mode 100644 index c71a1dc8cb..0000000000 --- a/changes/17557-ui-mdm-off-tooltip +++ /dev/null @@ -1 +0,0 @@ -- Removed outdated tooltips from UI. \ No newline at end of file diff --git a/changes/17559-batch-set-duplicate-mdm b/changes/17559-batch-set-duplicate-mdm deleted file mode 100644 index f037326fff..0000000000 --- a/changes/17559-batch-set-duplicate-mdm +++ /dev/null @@ -1 +0,0 @@ -- Added cross-platform check for duplicate MDM profiles names in batch set MDM profiles API. diff --git a/changes/17562-windows-server-2019-os-details b/changes/17562-windows-server-2019-os-details deleted file mode 100644 index e3aa773a03..0000000000 --- a/changes/17562-windows-server-2019-os-details +++ /dev/null @@ -1 +0,0 @@ -- Fixed a bug where OS version information would not get detected on Windows Server 2019 diff --git a/changes/17563-windows-add b/changes/17563-windows-add deleted file mode 100644 index 369ed30840..0000000000 --- a/changes/17563-windows-add +++ /dev/null @@ -1 +0,0 @@ -- Fixes an issue with Windows MDM profile processing where `` commands were being skipped. \ No newline at end of file diff --git a/changes/17621-bulk-delete-hosts-all-teams b/changes/17621-bulk-delete-hosts-all-teams deleted file mode 100644 index ed210b1655..0000000000 --- a/changes/17621-bulk-delete-hosts-all-teams +++ /dev/null @@ -1 +0,0 @@ -- Fix UI's ability to bulk delete hosts when "All teams" is selected diff --git a/changes/17624-modal-flash-message-error b/changes/17624-modal-flash-message-error deleted file mode 100644 index 52167d895b..0000000000 --- a/changes/17624-modal-flash-message-error +++ /dev/null @@ -1 +0,0 @@ -* Fix flash message from closing when a modal closes \ No newline at end of file diff --git a/changes/17662-render-standard-query-platforms-correctly b/changes/17662-render-standard-query-platforms-correctly deleted file mode 100644 index c625264580..0000000000 --- a/changes/17662-render-standard-query-platforms-correctly +++ /dev/null @@ -1 +0,0 @@ -- Fixes UI bug to render the query platform correctly for queries imported from the standard query library diff --git a/changes/17692-enrollment-state-3.md b/changes/17692-enrollment-state-3.md deleted file mode 100644 index 5703a31fd2..0000000000 --- a/changes/17692-enrollment-state-3.md +++ /dev/null @@ -1 +0,0 @@ -- Fix a bug where valid MDM enrollments would show up as unmanaged (EnrollmentState 3) diff --git a/changes/17733-innodb-lock-waits b/changes/17733-innodb-lock-waits deleted file mode 100644 index fc81532772..0000000000 --- a/changes/17733-innodb-lock-waits +++ /dev/null @@ -1 +0,0 @@ -In fleetctl debug db-locks (GET debug/db/locks) and fleetctl debug db-innodb-status (GET debug/db/innodb-status), fixed 500 error in MySQL 8 and when DB user has insufficient privileges. diff --git a/changes/17787-hidden-columns b/changes/17787-hidden-columns deleted file mode 100644 index 79509a758f..0000000000 --- a/changes/17787-hidden-columns +++ /dev/null @@ -1 +0,0 @@ -- UI and website show hidden columns in schema with a note that they won't be returned by running select \* from table diff --git a/changes/17897-api-resend-mdm-profile b/changes/17897-api-resend-mdm-profile deleted file mode 100644 index 8bbdf7dd1a..0000000000 --- a/changes/17897-api-resend-mdm-profile +++ /dev/null @@ -1 +0,0 @@ -- Added API to support resending MDM profiles. diff --git a/changes/17899-add-manual-labels-api b/changes/17899-add-manual-labels-api deleted file mode 100644 index 75f2b4ba14..0000000000 --- a/changes/17899-add-manual-labels-api +++ /dev/null @@ -1 +0,0 @@ -* Updated the `POST /api/v1/fleet/labels` and `PATCH /api/v1/fleet/labels/{id}` endpoints to support creation and update of manual labels. diff --git a/changes/17927-fix-styling-for-live-query-disabled-warning b/changes/17927-fix-styling-for-live-query-disabled-warning deleted file mode 100644 index 42323137fb..0000000000 --- a/changes/17927-fix-styling-for-live-query-disabled-warning +++ /dev/null @@ -1 +0,0 @@ -- UI fix: styling of live query disabled warning diff --git a/changes/17946-fleetd-chrome-numbers b/changes/17946-fleetd-chrome-numbers deleted file mode 100644 index c26bffdd51..0000000000 --- a/changes/17946-fleetd-chrome-numbers +++ /dev/null @@ -1,2 +0,0 @@ -- Fix a bug where values not derived from "actual" fleetd-chrome tables were not being displayed - correctly (e.g., `SELECT 1` gets its value from the query itself, not a table) diff --git a/changes/18060-host-activity-styling-bugs b/changes/18060-host-activity-styling-bugs deleted file mode 100644 index fb157bbbaf..0000000000 --- a/changes/18060-host-activity-styling-bugs +++ /dev/null @@ -1 +0,0 @@ -- Styling bug fixes of host details page activities (Remove trailing dash line from last activity, Re-instate padding below last activity) diff --git a/changes/18065-calendar-config-panic b/changes/18065-calendar-config-panic deleted file mode 100644 index 4a4a82176b..0000000000 --- a/changes/18065-calendar-config-panic +++ /dev/null @@ -1 +0,0 @@ -Fixing potential server panic when events are created with calendar integration, but then global calendar integration is disabled. diff --git a/changes/18081-upload-apple-profile-error-message b/changes/18081-upload-apple-profile-error-message deleted file mode 100644 index 4b6ad0f0da..0000000000 --- a/changes/18081-upload-apple-profile-error-message +++ /dev/null @@ -1 +0,0 @@ -* Fixed the error message so that it indicates if a conflict error on uploading an Apple profile was caused by the profile's name or its identifier. diff --git a/changes/18083-no-values-in-host-details-query-reports b/changes/18083-no-values-in-host-details-query-reports deleted file mode 100644 index 1c1a19a367..0000000000 --- a/changes/18083-no-values-in-host-details-query-reports +++ /dev/null @@ -1 +0,0 @@ -- Fix a bug where values were not being rendered in host-specific query reports. diff --git a/changes/18084-hdp-empty-state-padding b/changes/18084-hdp-empty-state-padding deleted file mode 100644 index 59c7ceb95c..0000000000 --- a/changes/18084-hdp-empty-state-padding +++ /dev/null @@ -1 +0,0 @@ -- UI fix: padding around empty states of host details page diff --git a/changes/18126-steam-vulns b/changes/18126-steam-vulns deleted file mode 100644 index c80ab6630c..0000000000 --- a/changes/18126-steam-vulns +++ /dev/null @@ -1,3 +0,0 @@ -Ignoring Valve Corporation's Steam client's vulnerabilities on Windows and macOS - - On Windows and macOS, the true version of the Steam client (like 2021-04-10) cannot be retrieved by standard methods used on other software. We would need to create custom logic to retrieve the version of the Steam client. - - Steam client automatically updates itself, so security risk is somewhat mitigated. diff --git a/changes/18142-fix-migration-issue-related-to-collation b/changes/18142-fix-migration-issue-related-to-collation deleted file mode 100644 index cf48ada6d8..0000000000 --- a/changes/18142-fix-migration-issue-related-to-collation +++ /dev/null @@ -1 +0,0 @@ -* Fixed an issue with the `20240327115617_CreateTableNanoDDMRequests` database migration where it could fail if the database did not default to the `utf8mb4_unicode_ci` collation. diff --git a/changes/18160-fix-release-device-with-pending-ddm-profiles b/changes/18160-fix-release-device-with-pending-ddm-profiles deleted file mode 100644 index d780f184f3..0000000000 --- a/changes/18160-fix-release-device-with-pending-ddm-profiles +++ /dev/null @@ -1 +0,0 @@ -* Fixed an issue with automatic release of the device after setup when a DDM profile is pending. diff --git a/changes/18256-calendar-feature-url-validation b/changes/18256-calendar-feature-url-validation deleted file mode 100644 index 3866e324c5..0000000000 --- a/changes/18256-calendar-feature-url-validation +++ /dev/null @@ -1 +0,0 @@ -- Update calendar events automations to not show error validation on enabling the feature diff --git a/changes/18276-fix-schema-button-location b/changes/18276-fix-schema-button-location deleted file mode 100644 index 8d76c7bc72..0000000000 --- a/changes/18276-fix-schema-button-location +++ /dev/null @@ -1 +0,0 @@ -* UI Fix to Show schema button location \ No newline at end of file diff --git a/changes/18299-gitops-calendar-validation b/changes/18299-gitops-calendar-validation deleted file mode 100644 index 7de5bae83b..0000000000 --- a/changes/18299-gitops-calendar-validation +++ /dev/null @@ -1 +0,0 @@ -Fixed fleetctl gitops dry-run validation issues when enabling calendar integration for the first time. diff --git a/changes/18350-calendar-event-for-invalid-sql b/changes/18350-calendar-event-for-invalid-sql deleted file mode 100644 index def1833cf8..0000000000 --- a/changes/18350-calendar-event-for-invalid-sql +++ /dev/null @@ -1 +0,0 @@ -For calendar integration, calendar event no longer created when policy has an invalid SQL query. diff --git a/changes/issue-17409-add-ddm-activities-to-ui b/changes/issue-17409-add-ddm-activities-to-ui deleted file mode 100644 index 0c0c267a32..0000000000 --- a/changes/issue-17409-add-ddm-activities-to-ui +++ /dev/null @@ -1 +0,0 @@ -- add ddm activities to the fleet UI diff --git a/changes/issue-17416-update-ui-to-support-ddm b/changes/issue-17416-update-ui-to-support-ddm deleted file mode 100644 index 3bbe4eaaa9..0000000000 --- a/changes/issue-17416-update-ui-to-support-ddm +++ /dev/null @@ -1 +0,0 @@ -- update UI to support macos DDM profiles. diff --git a/changes/issue-17417-ui-os-updates-ddm b/changes/issue-17417-ui-os-updates-ddm deleted file mode 100644 index 06386f9dc6..0000000000 --- a/changes/issue-17417-ui-os-updates-ddm +++ /dev/null @@ -1 +0,0 @@ -- change UI on OS Updates page to show new nudge for macos DDM diff --git a/changes/issue-17476-get-bitlocker-status b/changes/issue-17476-get-bitlocker-status deleted file mode 100644 index fbd4fb78cf..0000000000 --- a/changes/issue-17476-get-bitlocker-status +++ /dev/null @@ -1,2 +0,0 @@ -- Fixed issue where getting host details failed when attempting to read the host's bitlocker status - from the datastore. diff --git a/changes/issue-17896-ui-resend-profile b/changes/issue-17896-ui-resend-profile deleted file mode 100644 index 3911edd2bf..0000000000 --- a/changes/issue-17896-ui-resend-profile +++ /dev/null @@ -1 +0,0 @@ -- add UI for resending a profile for a host on the host details page in the OS Settings modal diff --git a/changes/issue-17898-new-manual-lables b/changes/issue-17898-new-manual-lables deleted file mode 100644 index 99c2eaef87..0000000000 --- a/changes/issue-17898-new-manual-lables +++ /dev/null @@ -1 +0,0 @@ -- implement manual labels in fleet UI diff --git a/changes/issue-18082-os-settings-stylings b/changes/issue-18082-os-settings-stylings deleted file mode 100644 index 1e3d8dca2c..0000000000 --- a/changes/issue-18082-os-settings-stylings +++ /dev/null @@ -1,2 +0,0 @@ -- update styling of os settings modal table to have all cells have the same width and have content -truncated when needed. diff --git a/changes/issue-18483-fix-download-enroll-profile b/changes/issue-18483-fix-download-enroll-profile deleted file mode 100644 index 9a5ce3f685..0000000000 --- a/changes/issue-18483-fix-download-enroll-profile +++ /dev/null @@ -1 +0,0 @@ -- fix issue with downloading manual enrollment profile on the my device page diff --git a/changes/license-comparison b/changes/license-comparison deleted file mode 100644 index e17ede70fc..0000000000 --- a/changes/license-comparison +++ /dev/null @@ -1 +0,0 @@ -* Fixed license checks to allow migration and restoring DEP devices during trial diff --git a/changes/min-node-version b/changes/min-node-version deleted file mode 100644 index 61a499cc02..0000000000 --- a/changes/min-node-version +++ /dev/null @@ -1 +0,0 @@ -- add a minimum supported node version in the package.json diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index d8c4e0ed21..6ccfba23e6 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,7 +8,7 @@ version: v6.0.2 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.48.3 +appVersion: v4.49.0 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index c48a3b5df9..ee3ec70068 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -2,7 +2,7 @@ # All settings related to how Fleet is deployed in Kubernetes hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy -imageTag: v4.48.3 # Version of Fleet to deploy +imageTag: v4.49.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: diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index 592383ad2d..833f16bf3a 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.48.3" + default = "fleetdm/fleet:v4.49.0" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index a1b08bab80..2e600c65a7 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,5 +68,5 @@ variable "redis_mem" { } variable "image" { - default = "fleet:v4.48.3" + default = "fleet:v4.49.0" } diff --git a/terraform/README.md b/terraform/README.md index f50b23c952..5c5a8cc103 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -75,7 +75,7 @@ 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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.48.3")
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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.0")
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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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 | | [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 | diff --git a/terraform/byo-vpc/README.md b/terraform/byo-vpc/README.md index 651d206147..bd18a989e2 100644 --- a/terraform/byo-vpc/README.md +++ b/terraform/byo-vpc/README.md @@ -34,7 +34,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 | <<<<<<< HEAD -| [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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.48.3")
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)
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.0")
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)
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.48.0")
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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"volumes": []
}
| no | >>>>>>> 025004bcf (support private registry in the ecs task definition) diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index 907f153cb2..e1b4e0e9c1 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -13,7 +13,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.48.3") + image = optional(string, "fleetdm/fleet:v4.49.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index ca169eebfa..b3f21d59b1 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -74,7 +74,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.48.3") + image = optional(string, "fleetdm/fleet:v4.49.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index 897ec9ef89..f4b0598e6e 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.48.3" + fleet_image = "fleetdm/fleet:v4.49.0" domain_name = "example.com" } diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index 66ed4ef168..1d57358551 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -167,7 +167,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.48.3") + image = optional(string, "fleetdm/fleet:v4.49.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/example/main.tf b/terraform/example/main.tf index 9b1b314ff5..f272c8958d 100644 --- a/terraform/example/main.tf +++ b/terraform/example/main.tf @@ -59,8 +59,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.48.3" - image = "fleetdm/fleet:v4.48.3" # override default to deploy the image you desire + # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.49.0" + image = "fleetdm/fleet:v4.49.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 = { diff --git a/terraform/variables.tf b/terraform/variables.tf index d72f733ea1..ee42f916a3 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -215,7 +215,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.48.3") + image = optional(string, "fleetdm/fleet:v4.49.0") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index cc84162fe0..27b188c7c5 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.48.3", + "version": "v4.49.0", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" From 5ad5e170765f0a901e882c9e702c502c2083c2a6 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 24 Apr 2024 12:40:53 -0700 Subject: [PATCH 07/83] Article: Fleet release 4.49.0 (#18512) --- ...y-management-with-vulncheck-integration.md | 42 ++++++ articles/fleet-4.49.0.md | 140 ++++++++++++++++++ ...with-vulncheck-integration-1600x900@2x.png | Bin 0 -> 44914 bytes .../articles/fleet-4.49.0-1600x900@2x.png | Bin 0 -> 52701 bytes 4 files changed, 182 insertions(+) create mode 100644 articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration.md create mode 100644 articles/fleet-4.49.0.md create mode 100644 website/assets/images/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration-1600x900@2x.png create mode 100644 website/assets/images/articles/fleet-4.49.0-1600x900@2x.png diff --git a/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration.md b/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration.md new file mode 100644 index 0000000000..ccb5d6eea9 --- /dev/null +++ b/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration.md @@ -0,0 +1,42 @@ +# Enhancing Fleet's vulnerability management with VulnCheck integration + +![Enhancing Fleet's vulnerability management with VulnCheck integration](../website/assets/images/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration-1600x900@2x.png) + +Staying ahead of threats is paramount. For Fleet users, this means having the right tools and ensuring they are optimized for performance and efficiency. We're excited to include VulnCheck for centralized Common Platform Enumeration (CPE) data. + +IT administrators and CISOs have long found managing CPE data across diverse environments challenging. The sheer volume of information and the need for accuracy and timeliness can strain resources and compromise security. Recognizing this, Fleet partnered with VulnCheck for a solution to streamline CPE data management and enhance security workflows. + + +### CPE data + +Common Platform Enumeration is a vital component in identifying and mitigating vulnerabilities. CPEs provide a standardized nomenclature for describing and identifying the software applications and operating systems affected by specific vulnerabilities. With the National Vulnerability Database (NVD) sometimes lagging in attaching CPEs to recent vulnerabilities—some published over a month late—Fleet users face the dilemma of potential blind spots in their vulnerability detection mechanisms. Since Fleet relies on CPEs to match software inventory collected via osquery to CVEs, any delay or absence in CPE data from the NVD directly impacts Fleet's ability to accurately detect and address CVEs, posing significant implications for performance and security. + + +### Introducing VulnCheck + +At its core, VulnCheck acts as a reliable bridge to the NIST National Vulnerability Database (NVD), ensuring persistent and accurate connections to the latest CVE data. Leveraging its high-performance API and downloadable CVE data, VulnCheck offers Fleet users unparalleled access to up-to-date vulnerability information. What sets VulnCheck apart is its integration of NVD++, a community-driven initiative to enhance the reliability and accessibility of NVD data. By tapping into NVD++, VulnCheck provides Fleet users with a comprehensive repository of CPE data, regardless of delays in the official NVD feed. This centralized approach to CPE data management streamlines the vulnerability detection process within Fleet, empowering IT administrators to identify and remediate security threats swiftly. The benefits of integrating VulnCheck into Fleet are manifold: enhanced accuracy in vulnerability detection, improved timeliness of threat response, and, ultimately, strengthened security posture for organizations of all sizes. This strategic partnership fortifies organizations' security posture and empowers administrators with the tools to manage and mitigate potential threats proactively. + + +### Enhanced Data Reliability with VulnCheck + +The integration of VulnCheck significantly bolsters the reliability of the data Fleet uses for vulnerability management. Recently, the National Vulnerability Database (NVD) faced disruptions that affected its ability to enrich CVEs with crucial matching data, leading to potential vulnerabilities remaining undetected. This is where VulnCheck steps in with its NVD++ service. By enriching its feeds independently, VulnCheck offers a more consistent and reliable data source, ensuring that Fleet users do not experience gaps in vulnerability detection. This is especially critical when the NVD data pipeline faces lags or interruptions, as was seen with several CVEs pending analysis and lacking essential software matching data. + + +### Streamlining Vulnerability Management + +The practical implications of integrating VulnCheck's enriched data are profound. For instance, the initial synchronization time for setting up vulnerability management in Fleet has been substantially reduced. Where it previously took about 17 minutes to load NVD data—a considerable delay for new users—this process is now more efficient thanks to enriched data from VulnCheck and pre-processing by Fleet. Moreover, the reliability of data fetch operations has improved with fewer retries needed, mitigating the impact of NVD API's occasional unavailability. This enhancement allows Fleet to offer more immediate value to new users, significantly shortening the time to useful data and enabling faster, more reliable vulnerability scanning. + + +### Conclusion + +Staying ahead of threats with efficient tools and systems is not just an option but a necessity. Fleet and VulnCheck introduce a robust solution tailored to enhance the security frameworks of diverse organizations. This integration alleviates the strains of managing large volumes of security data by centralizing and streamlining CPE data management. It ensures that Fleet users can rely on up-to-date and accurate vulnerability information. Incorporating VulnCheck’s NVD++ service into Fleet’s vulnerability management process significantly advances our ability to offer timely and effective security responses, minimizing potential exposure to threats. As we continue to enhance our capabilities, Fleet remains committed to providing our users with the most reliable and efficient tools necessary to safeguard their digital environments against the ever-changing threat landscape. + + + + + + + + + + diff --git a/articles/fleet-4.49.0.md b/articles/fleet-4.49.0.md new file mode 100644 index 0000000000..bda46c13e2 --- /dev/null +++ b/articles/fleet-4.49.0.md @@ -0,0 +1,140 @@ +# Fleet 4.49.0 | VulnCheck's NVD++, device health API, `fleetd` data parsing. + +![Fleet 4.49.0](../website/assets/images/articles/fleet-4.49.0-1600x900@2x.png) + +Fleet 4.49.0 is live. Check out the full [changelog](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.49.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 + +* Enhancing Fleet's vulnerability management with VulnCheck integration +* Device health API includes critical policy and resolution data +* `fleetd` data parsing expansion +* Apply labels using UI or API +* Resend configuration profiles + + + +### Enhancing Fleet's vulnerability management with VulnCheck integration + +Fleet is integrating VulnCheck to enhance our vulnerability management capabilities, ensuring our users can manage Common Platform Enumeration (CPE) data more effectively and securely. Utilizing VulnCheck's NVD++ service, Fleet will provide reliable, timely access to vulnerability data, overcoming delays and inconsistencies in the National Vulnerability Database (NVD). This integration improves the accuracy and timeliness of threat detection and streamlines the overall vulnerability management process, empowering IT administrators to identify and mitigate security threats swiftly. Learn more about how this enhancement strengthens Fleet's security framework in our latest blog post: [Enhancing Fleet's Vulnerability Management with VulnCheck Integration](https://fleetdm.com/announcements/enhancing-fleets-vulnerability-management-with-vulncheck-integration). + + +### Device health API includes critical policy and resolution data + +Fleet has updated its device health API to include critical and policy resolution data, enhancing the utility of this API for specific workflow conditions where compliance verification is essential before proceeding. This update allows for real-time authentication checks to ensure a host complies with set policies, thereby supporting secure and compliant operational workflows. By integrating critical compliance data into the device health API, Fleet enables administrators to enforce and verify security policies efficiently, ensuring that only compliant devices proceed in sensitive or critical operations. This enhancement supports thorough compliance management and reinforces secure practices within IT environments, streamlining processes where policy adherence is crucial. + + +### `fleetd` data parsing expansion + +Fleet's agent (`fleetd`) has expanded its data parsing capabilities by adding support for JSON, JSONL, XML, and INI file formats as tables. This functionality allows for more versatile data extraction and management, enabling users to convert these popular data formats directly into queryable tables. This capability is particularly useful for IT and security teams who need to analyze and monitor configuration and data files across various systems within their digital environments efficiently. By facilitating integration and manipulation of data from these diverse formats, Fleet helps ensure that teams can maintain better oversight and faster responsiveness when managing operational and security needs. This feature is a natural extension of Fleet's ongoing efforts to empower IT professionals with comprehensive tools for robust data handling and security management. + + +### Apply labels using UI or API + +Fleet has expanded the flexibility of label management by enabling users to add labels manually through both the UI and API. This capability was previously available only via the CLI. This enhancement allows administrators to more conveniently categorize and manage hosts directly within the user interface or programmatically via the API, aligning with various operational workflows. By streamlining the label application process, Fleet makes it easier for teams to organize and access host data according to specific criteria, thereby improving operational efficiency and responsiveness. This update supports better integration and automation capabilities within IT environments, empowering users to maintain organized and effective device management practices. + + +### Resend configuration profiles + +Fleet has introduced a new feature that allows users to resend a configuration profile to a host, which is crucial for maintaining current settings and certificates. This functionality is particularly beneficial in scenarios where renewing SCEP certificates, signing certificates need updating, or reapplication of existing configurations is required to ensure continuity and compliance. By enabling the reissuance of configuration profiles directly from the platform, Fleet supports continuous device management and security upkeep, facilitating a proactive approach to maintaining and securing digital environments. This feature enhances Fleet's utility for administrators by simplifying the management of device configurations. + + + +## Changes + +### Endpoint operations + +- Added integration with Google Calendar for policy compliance events. +- Added new API endpoints to add/remove manual labels to/from a host. +- Updated the `POST /api/v1/fleet/labels` and `PATCH /api/v1/fleet/labels/{id}` endpoints to support creation and update of manual labels. +- Implemented changes in `fleetctl gitops` for batch processing queries and policies. +- Enabled setting host status webhook at the team level via REST API and fleetctl apply/gitops. + +### Device management (MDM) + +- Added API functionality for creating DDM declarations, both individually and as a batch. +- Added creation or update of macOS DDM profile to enforce OS Updates settings whenever the settings are changed. +- Updated `fleetctl run-script` to include new `--team` and `--script-name` flags. +- Displayed disk encryption status in macOS as "verifying" while verifying the escrowed key. +- Added the `enable_release_device_manually` configuration setting for teams and no team, which controls the automatic release of a macOS DEP-enrolled device. +- Updated the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host. +- Added the `enable_release_device_manually` configuration, which affects macOS automatic enrollment profile settings. + +### Vulnerability management + +- Ignored Valve Corporation's Steam client's vulnerabilities on Windows and macOS due to retrieval challenges of the true version. +- Updated the GET fleet/os_versions and GET fleet/os_versions/[id] to restrict team users from accessing os versions on hosts from other teams. + +### Bug fixes and improvements + +- Upgraded Golang version to 1.21.7. +- Added a minimum supported node version in the `package.json`. +- Made block_id mismatch errors more informative as 400s instead of 500s. +- Added Windows MDM support to the `osquery-perf` host-simulation command. +- Updated calendar events automations to not show error validation on enabling the feature. +- Migrated MDM-related endpoints to new paths while maintaining support for old endpoints indefinitely. +- Added a missing database index to the MDM Windows enrollments table to improve performance at scale. +- Added cross-platform check for duplicate MDM profiles names in batch set MDM profiles API. +- Fixed a bug where Microsoft Edge was not reporting vulnerabilities. +- Fixed an issue with the `20240327115617_CreateTableNanoDDMRequests` database migration. +- Fixed the error message to indicate if a conflict on uploading an Apple profile was caused by the profile's name or its identifier. +- Fixed license checks to allow migration and restoring DEP devices during trial. +- Fixed a 500 error in MySQL 8 and when DB user has insufficient privileges for `fleetctl debug db-locks` and `fleetctl debug db-innodb-status`. +- Fixed a bug where values not derived from "actual" fleetd-chrome tables were not being displayed correctly. +- Fixed a bug where values were not being rendered in host-specific query reports. +- Fixed an issue with automatic release of the device after setup when a DDM profile is pending. +- Fixed UI issues: alignment bugs, padding around empty states, tooltip rendering, and incorrect rendering of the global Host status expiry settings page. +- Fixed a bug where `null` or excluded `smtp_settings` caused a UI 500 error. +- Fixed an issue where a bad request response from a 3rd party MDM solution would result in a 500 error in Fleet during MDM migration. +- Fixed a bug where updating policy name could result in multiple policies with the same name in a team. +- Fixed potential server panic when events are created with calendar integration, but then global calendar integration is disabled. +- Fixed fleetctl gitops dry-run validation issues when enabling calendar integration for the first time. +- Fixed a bug where all Windows MDM enrollments were detected as automatic. + +## Fleet 4.48.3 (Apr 16, 2024) + +### Bug fixes + +* Updated calendar webhook to retry if it receives response 429 "Too Many Requests". Webhook request will retry for 30 minutes with a 1 minute max delay between retries. +* Updated label endpoints and UI to prevent creating, updating, or deleting built-in labels. +* Fixed edge cases of team ID being lost in various flows. +* Fixed queries to correctly parse params for `GET` ...`policies/count`, `GET` ...`teams/:id/policies/count`, and `GET` ...`vulnerabilities`. +* Fixed 'GET` ...`labels` to return `400` when the non-supported `query` url param was included in the request. Previous behavior was to silently ignore that param and return `200`. +* Casted windows exit codes to signed integers to match windows interpreter. +* Fixed a bug where some scripts got stuck in "upcoming" activity permanently. +* Fixed a bug where the translate API returned "forbidden" instead of "bad request" for an empty JSON body. +* Fixed an uncaught bug where "forbidden" would be returned for invalid payload type, which should also be a bad request. +* Fixed an issue where applying Windows MDM profiles using `fleetctl apply` would cause Fleet to overwrite the reserved profile used to manage Windows OS updates. +* Fixed a bug where we were not ignoreing leading and trailing whitespace when filtering Fleet entities by name. +* Fixed a bug where query retrieving bitlocker info from windows server wouldn't return. +* Fixed MDM migration starting when the device didn't have the right ADE JSON profile already assigned. + +## Fleet 4.48.2 (Apr 09, 2024) + +### Bug fixes + +* Fixed an issue with the `20240327115617_CreateTableNanoDDMRequests` database migration where it could fail if the database did not default to the `utf8mb4_unicode_ci` collation. +* Fixed an issue with automatic release of the device after setup when a DDM profile is pending. + +## Fleet 4.48.1 (Apr 08, 2024) + +### Bug fixes + +- Made block_id mismatch errors more informative as 400s instead of 500s +- Fixed a bug where values were not being rendered in host-specific query reports +- Fixed potential server panic when events are created with calendar integration, but then global calendar integration is disabled + + + + +## 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.49.0. + + + + + + + diff --git a/website/assets/images/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration-1600x900@2x.png b/website/assets/images/articles/enhancing-fleets-vulnerability-management-with-vulncheck-integration-1600x900@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fad1e5a4b03849248a770931f67c467008725752 GIT binary patch literal 44914 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 X|K8bOw$eNWUf}HM>gTe~DWM4fDbzCe literal 0 HcmV?d00001 diff --git a/website/assets/images/articles/fleet-4.49.0-1600x900@2x.png b/website/assets/images/articles/fleet-4.49.0-1600x900@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1f85b9c2b9e583e9d02f02b483aa8eca7f8756b9 GIT binary patch literal 52701 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/J{fTK*;nhQ!4+P&YVe~hhY-7!}!4g4B~e@R$) zHi$e1hEo(z*>TBRfqCtvS|eOd-11U4U$6Ocs=*SW2>WZTvO8eZ39JFR{~M?SyP?B( zr=x+<*$p5nzzmQ)o-LW)7li5Dr)SB}&#fuUWrBX;KYT8fMEDfiG% z4b6r)_XOplnlA!za`zB$foU61beGj39+j0monO5Rj_Z=a{s|M)=6lypiSiMlR==dE z<_kU%B8KvrdgT+P8z1s1_f%K)y%W5waqLjq-Ch(w0G6>f@yZJVVr9MoV<0isnNLe~ z)fKR4BD_c%y8#q1KziP{1T>T8?!1FXU*2OBqXZCx2Ox99s$d6ZWE{t#5qO}`7r&V# zW%D&G;8QN9V8C}B6ZKO!SZ}W7yBZ@Q(xcd!@OAT3)&PG9Z+s0k12SMh$>}#ou^;mIH?(T9tbPe@=;t4y4WngQ4~t+UO47cA zhj_$zR(6a}Ck5Q^$u278zl?eOO!-eU){=KRAu3TlFu3(pl%W|;hoGSD;yn@d$r&k& z!TTo#MeWFgT?pNq_Y98Vm5EfmA-gA7$~=GR==$^zi`LUZdN$dzwH;0yVCtj-#SHD6 zq4_Mp1|bt;pwB=gJkX!h!pC&HHa!0aFA^ZvxplwQgZ8Nbe>>qI?ZE1K2&{0#(8Fl(~;NAgitXRDX^a98*pk)?Ot!9l!t@ZWnZk8 zeBJOzHdNBa4``54Y8dh2Ynhe7ID!aD02Gfx)S;^cVr0i&dA=YO}w1#+}&Rm)`o4PUYRKJ;0Re5pwj z$*-5b@?zapCoM@>^BLb$8`>@-yjZmNwUJ?;zJTDQo()~1TMmAF8MRno(*4%Q=## zbML;PYlbr;pkMzUV~6%k^#0Sp^Z)sVMCcX%oBzf=@BRPatp{Q{P>FpVfE}EieMuas z#DPluf9p6Xp}p-q75Hz9q2G;1`2Y9Q2jqKIf&G^dZ()$%{r6xL3jFsc^!wng`_ecN z(?7L1P>BPTIIt4`PQ!sQ-Iv6HN*t)ffs_2F0|!oWUlIo@ai9{g11Gt!1P4y?-)T5d zi364RKjS2;{1w?CS#nIcC`a;4LagYs|6RoR_TTjnxBset)ctq;ua)4n7Q zRN_D-4y?pK9XK$i`;s_Ni3625aFYLY;J``#lf*&Xv@eMRl{ip|11I@U2M*$ Date: Wed, 24 Apr 2024 14:54:50 -0500 Subject: [PATCH 08/83] Stub out basis for AI-generated employee experience tips and risk register (#18501) Add new fleetdm.com API endpoint for #18187 --------- Co-authored-by: Mike McNeil --- ...t-human-interpretation-from-osquery-sql.js | 101 ++++++++++++++++++ website/assets/js/cloud.setup.js | 2 +- website/config/policies.js | 1 + website/config/routes.js | 1 + 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 website/api/controllers/get-human-interpretation-from-osquery-sql.js diff --git a/website/api/controllers/get-human-interpretation-from-osquery-sql.js b/website/api/controllers/get-human-interpretation-from-osquery-sql.js new file mode 100644 index 0000000000..e31485c5dc --- /dev/null +++ b/website/api/controllers/get-human-interpretation-from-osquery-sql.js @@ -0,0 +1,101 @@ +module.exports = { + + + friendlyName: 'Get human interpretation from osquery sql', + + + description: 'Infer policy information from osquery SQL.', + + + inputs: { + + sql: { + type: 'string', + required: true + }, + + }, + + + exits: { + + success: { + outputFriendlyName: 'Humanesque interpretation', + outputDescription: 'If the call to the LLM fails, then a success response is sent with an explanation about the failure (e.g. "under heavy load", etc)', + outputExample: { + risks: 'Using an outdated macOS version risks exposure to security vulnerabilities and potential system instability.', + whatWillProbablyHappenDuringMaintenance: 'We will update your macOS to version 14.4.1 to enhance security and stability.' + } + }, + + }, + + + fn: async function ({sql}) { + + if (!sails.config.custom.openAiSecret) { + throw new Error('sails.config.custom.openAiSecret not set.'); + }//• + + // Build our prompt + let prompt = `Given this osquery policy: aka a query which either passes (≥1 row) or fails (0 rows) for a given laptop, what risks might we anticipate from that laptop having failed the policy? + +Here is the query: +\`\`\` +${sql} +\`\`\` + +Remember to minimize the number of words used! + +Please give me all of the above in JSON, with this data shape: + +\`\`\` +{ + risks: 'TODO', + whatWillProbablyHappenDuringMaintenance: 'TODO' +} +\`\`\``; + // Fallback message in case LLM API request fails. + let failureMessage = 'Failed to generate human interpretation using generative AI.'; + + let BASE_MODEL = 'gpt-4';// The base model to use. https://platform.openai.com/docs/models/gpt-4 + // (Max tokens for gpt-3.5 ≈≈ 4000) (Max tokens for gpt-4 ≈≈ 8000) + // [?] API: https://platform.openai.com/docs/api-reference/chat/create + let llmReport = await sails.helpers.http.post('https://api.openai.com/v1/chat/completions', { + model: BASE_MODEL, + messages: [// https://platform.openai.com/docs/guides/chat/introduction + { + role: 'user', + content: prompt + } + ], + temperature: 0.7, + max_tokens: 256//eslint-disable-line camelcase + }, { + Authorization: `Bearer ${sails.config.custom.openAiSecret}` + }) + .tolerate((err)=>{ + sails.log.warn(failureMessage+' Error details from LLM: '+err.stack); + return; + }); + + // Get data into expected formaat + let report; + if (!llmReport) {// If LLM could not be reached… + // FUTURE: Actually negotiate errors instead of just pretending it works but sending back garbage. + report = { + risks: failureMessage, + whatWillProbablyHappenDuringMaintenance: failureMessage + }; + } else {// Otherwise, descriptions were successfully generated… + let llmMessage = llmReport.choices[0].message.content; + llmMessage = llmMessage.replace(/\`\`\`/g, ''); + report = JSON.parse(llmMessage); + } + + return report; + + } + + +}; diff --git a/website/assets/js/cloud.setup.js b/website/assets/js/cloud.setup.js index cf4a555bd5..ab9ebae327 100644 --- a/website/assets/js/cloud.setup.js +++ b/website/assets/js/cloud.setup.js @@ -13,7 +13,7 @@ Cloud.setup({ /* eslint-disable */ - methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"downloadRssFeed":{"verb":"GET","url":"/rss/:categoryName","args":["categoryName"]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostsStatusWebHookEnabled","numWeeklyActiveUsers","numWeeklyPolicyViolationDaysActual","numWeeklyPolicyViolationDaysPossible","hostsEnrolledByOperatingSystem","hostsEnrolledByOrbitVersion","hostsEnrolledByOsqueryVersion","storedErrors","numHostsNotResponding","organization","mdmMacOsEnabled","mdmWindowsEnabled","liveQueryDisabled","hostExpiryEnabled"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label","release"]},"receiveFromStripe":{"verb":"POST","url":"/api/v1/webhooks/receive-from-stripe","args":["id","type","data","webhookSecret"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName","signupReason","primaryBuyingSituation"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","organization","firstName","lastName","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":["emailAddress"]},"createOrUpdateOneNewsletterSubscription":{"verb":"POST","url":"/api/v1/create-or-update-one-newsletter-subscription","args":["emailAddress","subscribeTo"]},"unsubscribeFromAllNewsletters":{"verb":"GET","url":"/api/v1/unsubscribe-from-all-newsletters","args":["emailAddress"]},"buildLicenseKey":{"verb":"POST","url":"/api/v1/admin/build-license-key","args":["numberOfHosts","organization","expiresAt","partnerName"]},"createVantaAuthorizationRequest":{"verb":"POST","url":"/api/v1/create-vanta-authorization-request","args":["emailAddress","fleetInstanceUrl","fleetApiKey"]},"deliverMdmBetaSignup":{"verb":"POST","url":"/api/v1/deliver-mdm-beta-signup","args":["emailAddress","fullName","jobTitle","numberOfHosts"]},"deliverAppleCsr":{"verb":"POST","url":"/api/v1/deliver-apple-csr","args":["unsignedCsrData"]},"deliverLaunchPartySignup":{"verb":"POST","url":"/api/v1/deliver-launch-party-signup","args":["emailAddress","firstName","lastName","jobTitle","phoneNumber"]},"deliverMdmDemoEmail":{"verb":"POST","url":"/api/v1/deliver-mdm-demo-email","args":["emailAddress"]},"provisionSandboxInstanceAndDeliverEmail":{"verb":"POST","url":"/api/v1/admin/provision-sandbox-instance-and-deliver-email","args":["userId"]},"deliverTalkToUsFormSubmission":{"verb":"POST","url":"/api/v1/deliver-talk-to-us-form-submission","args":["emailAddress","firstName","lastName","organization","numberOfHosts","primaryBuyingSituation"]},"saveQuestionnaireProgress":{"verb":"POST","url":"/api/v1/save-questionnaire-progress","args":["currentStep","formData"]}} + methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"downloadRssFeed":{"verb":"GET","url":"/rss/:categoryName","args":["categoryName"]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostsStatusWebHookEnabled","numWeeklyActiveUsers","numWeeklyPolicyViolationDaysActual","numWeeklyPolicyViolationDaysPossible","hostsEnrolledByOperatingSystem","hostsEnrolledByOrbitVersion","hostsEnrolledByOsqueryVersion","storedErrors","numHostsNotResponding","organization","mdmMacOsEnabled","mdmWindowsEnabled","liveQueryDisabled","hostExpiryEnabled"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label","release"]},"receiveFromStripe":{"verb":"POST","url":"/api/v1/webhooks/receive-from-stripe","args":["id","type","data","webhookSecret"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName","signupReason"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","organization","firstName","lastName","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":["emailAddress"]},"createOrUpdateOneNewsletterSubscription":{"verb":"POST","url":"/api/v1/create-or-update-one-newsletter-subscription","args":["emailAddress","subscribeTo"]},"unsubscribeFromAllNewsletters":{"verb":"GET","url":"/api/v1/unsubscribe-from-all-newsletters","args":["emailAddress"]},"buildLicenseKey":{"verb":"POST","url":"/api/v1/admin/build-license-key","args":["numberOfHosts","organization","expiresAt","partnerName"]},"createVantaAuthorizationRequest":{"verb":"POST","url":"/api/v1/create-vanta-authorization-request","args":["emailAddress","fleetInstanceUrl","fleetApiKey"]},"deliverMdmBetaSignup":{"verb":"POST","url":"/api/v1/deliver-mdm-beta-signup","args":["emailAddress","fullName","jobTitle","numberOfHosts"]},"getHumanInterpretationFromOsquerySql":{"verb":"POST","url":"/api/v1/get-human-interpretation-from-osquery-sql","args":["sql"]},"deliverAppleCsr":{"verb":"POST","url":"/api/v1/deliver-apple-csr","args":["unsignedCsrData"]},"deliverMdmDemoEmail":{"verb":"POST","url":"/api/v1/deliver-mdm-demo-email","args":["emailAddress"]},"provisionSandboxInstanceAndDeliverEmail":{"verb":"POST","url":"/api/v1/admin/provision-sandbox-instance-and-deliver-email","args":["userId"]},"deliverTalkToUsFormSubmission":{"verb":"POST","url":"/api/v1/deliver-talk-to-us-form-submission","args":["emailAddress","firstName","lastName","organization","numberOfHosts","primaryBuyingSituation"]},"saveQuestionnaireProgress":{"verb":"POST","url":"/api/v1/save-questionnaire-progress","args":["currentStep","formData"]}} /* eslint-enable */ }); diff --git a/website/config/policies.js b/website/config/policies.js index ebecbbea74..2c46a7f477 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -53,5 +53,6 @@ module.exports.policies = { 'try-fleet/view-explore-data': true, 'try-fleet/view-query-report': true, 'deliver-talk-to-us-form-submission': true, + 'get-human-interpretation-from-osquery-sql': true, 'customers/view-new-license': true, }; diff --git a/website/config/routes.js b/website/config/routes.js index daa4c7b4bc..9cb3f2c660 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -552,6 +552,7 @@ module.exports.routes = { 'POST /api/v1/admin/build-license-key': { action: 'admin/build-license-key' }, 'POST /api/v1/create-vanta-authorization-request': { action: 'create-vanta-authorization-request' }, 'POST /api/v1/deliver-mdm-beta-signup': { action: 'deliver-mdm-beta-signup' }, + 'POST /api/v1/get-human-interpretation-from-osquery-sql': { action: 'get-human-interpretation-from-osquery-sql', csrf: false }, 'POST /api/v1/deliver-apple-csr ': { action: 'deliver-apple-csr', csrf: false}, 'POST /api/v1/deliver-mdm-demo-email': { action: 'deliver-mdm-demo-email' }, 'POST /api/v1/admin/provision-sandbox-instance-and-deliver-email': { action: 'admin/provision-sandbox-instance-and-deliver-email' }, From 048827bea71350e7c8bcafbef8091eaed9f04d52 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 24 Apr 2024 15:53:36 -0500 Subject: [PATCH 09/83] Website: Fix testimonials.yml maintainers & DRI in custom config (#18522) Changes: - removed a leading slash from the path to `handbook/company/testimonials.yml` in the website's custom config --- website/config/custom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/config/custom.js b/website/config/custom.js index 1823de7a3a..a4dd83723e 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -148,7 +148,7 @@ module.exports.custom = { // 'website/views/pages/pricing.ejs': '', // « Covered in CODEOWNERS (2023-07-22) // 'handbook/company/pricing-features-table.yml': '', // « Covered in CODEOWNERS (2023-07-22) - '/handbook/company/testimonials.yml': 'mike-j-thomas', + 'handbook/company/testimonials.yml': 'mike-j-thomas', // 🫧 Other brandfronts 'README.md': 'mikermcneil',// « GitHub brandfront @@ -251,7 +251,7 @@ module.exports.custom = { 'handbook/sales': ['sampfluger88','mikermcneil'], 'handbook/demand': ['sampfluger88','mikermcneil'], 'handbook/customer-success': ['sampfluger88','mikermcneil'], - '/handbook/company/testimonials.yml': ['eashaw', 'mike-j-thomas', 'sampfluger88', 'mikermcneil'], + 'handbook/company/testimonials.yml': ['eashaw', 'mike-j-thomas', 'sampfluger88', 'mikermcneil'], // GitHub issue templates '.github/ISSUE_TEMPLATE': ['mikermcneil', 'lukeheath', 'sampfluger88'], From a7f350578582c6188e46d3b5ee18e6b33471724a Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Wed, 24 Apr 2024 16:52:36 -0500 Subject: [PATCH 10/83] =?UTF-8?q?Add=20redirect=20for=20UI=20=E2=80=94=20D?= =?UTF-8?q?ocs=20for=20rotating=20enroll=20secrets=20(#18520)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For #18239 (to fix a broken link in that modal) --- website/config/routes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/config/routes.js b/website/config/routes.js index 9cb3f2c660..7ad2a790a0 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -492,6 +492,7 @@ module.exports.routes = { 'GET /learn-more-about/enabling-calendar-api': 'https://console.cloud.google.com/apis/library/calendar-json.googleapis.com', 'GET /learn-more-about/downgrading': '/docs/using-fleet/downgrading-fleet', 'GET /learn-more-about/fleetd': '/docs/get-started/anatomy#fleetd', + 'GET /learn-more-about/rotating-enroll-secrets': '/docs/configuration/configuration-files#rotating-enroll-secrets', // Sitemap // ============================================================================================================= From d3b821cf47140ea7011d5da57a2ed67b6ef12786 Mon Sep 17 00:00:00 2001 From: George Karr Date: Wed, 24 Apr 2024 17:22:24 -0500 Subject: [PATCH 11/83] Adding in cherry-pick into prepare branch and push (#18499) --- tools/release/publish_release.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/release/publish_release.sh b/tools/release/publish_release.sh index 313774ab15..1ea01e37ac 100755 --- a/tools/release/publish_release.sh +++ b/tools/release/publish_release.sh @@ -855,6 +855,13 @@ if [[ "$failed" == "false" ]]; then echo "DRYRUN: Would have switched back to branch $target_patch_branch" fi + if [[ "$main_release" == "false" ]]; then + # Cherry-pick from update-changelog-branch + ch_commit=`git log -n 1 --pretty=format:"%H" $update_changelog_branch` + git cherry-pick $ch_commit + git push origin $target_patch_branch -f + fi + # Check for QA issue create_qa_issue From 3a6cf2e5990c0ecaaa9aa45f7493190370e88e8b Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:25:33 -0700 Subject: [PATCH 12/83] =?UTF-8?q?UI=20=E2=80=93=20render=20informative=20m?= =?UTF-8?q?essage=20when=20user=20tries=20to=20save=20query=20with=20inval?= =?UTF-8?q?id=20platform(s)=20(#18473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #17771 ![Screenshot 2024-04-22 at 1 10 06 PM](https://github.com/fleetdm/fleet/assets/61553566/6b92efbb-6a5c-49f3-b903-624c85fd2173) - [x] Changes file added for user-visible changes in `changes/` - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- changes/17771-invalid-query-platforms | 1 + frontend/pages/queries/edit/EditQueryPage.tsx | 15 ++++++++++++--- .../components/EditQueryForm/EditQueryForm.tsx | 14 +++++++++----- frontend/utilities/constants.tsx | 6 ++++++ 4 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 changes/17771-invalid-query-platforms diff --git a/changes/17771-invalid-query-platforms b/changes/17771-invalid-query-platforms new file mode 100644 index 0000000000..963a27eccc --- /dev/null +++ b/changes/17771-invalid-query-platforms @@ -0,0 +1 @@ +* Add an informative flash message when the user tries to save a query with invalid platform(s). diff --git a/frontend/pages/queries/edit/EditQueryPage.tsx b/frontend/pages/queries/edit/EditQueryPage.tsx index b2b04a136a..1c105e6220 100644 --- a/frontend/pages/queries/edit/EditQueryPage.tsx +++ b/frontend/pages/queries/edit/EditQueryPage.tsx @@ -5,7 +5,12 @@ import { InjectedRouter, Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; import { QueryContext } from "context/query"; -import { DEFAULT_QUERY, DOCUMENT_TITLE_SUFFIX } from "utilities/constants"; +import { + DEFAULT_QUERY, + DOCUMENT_TITLE_SUFFIX, + INVALID_PLATFORMS_FLASH_MESSAGE, + INVALID_PLATFORMS_REASON, +} from "utilities/constants"; import configAPI from "services/entities/config"; import queryAPI from "services/entities/queries"; import statusAPI from "services/entities/status"; @@ -15,6 +20,7 @@ import { ISchedulableQuery, } from "interfaces/schedulable_query"; import { IConfig } from "interfaces/config"; +import { getErrorReason } from "interfaces/errors"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; import MainContent from "components/MainContent"; @@ -229,7 +235,7 @@ const EditQueryPage = ({ renderFlash("success", "Query created!"); setBackendValidators({}); } catch (createError: any) { - if (createError.data.errors[0].reason.includes("already exists")) { + if (getErrorReason(createError).includes("already exists")) { const teamErrorText = teamNameForQuery && apiTeamIdForQuery !== 0 ? `the ${teamNameForQuery} team` @@ -275,8 +281,11 @@ const EditQueryPage = ({ refetchStoredQuery(); // Required to compare recently saved query to a subsequent save to the query } catch (updateError: any) { console.error(updateError); - if (updateError.data.errors[0].reason.includes("Duplicate")) { + const reason = getErrorReason(updateError); + if (reason.includes("Duplicate")) { renderFlash("error", "A query with this name already exists."); + } else if (reason.includes(INVALID_PLATFORMS_REASON)) { + renderFlash("error", INVALID_PLATFORMS_FLASH_MESSAGE); } else { renderFlash( "error", diff --git a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx index 512fe6d755..98fc461e3c 100644 --- a/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx +++ b/frontend/pages/queries/edit/components/EditQueryForm/EditQueryForm.tsx @@ -27,9 +27,11 @@ import { SCHEDULE_PLATFORM_DROPDOWN_OPTIONS, MIN_OSQUERY_VERSION_OPTIONS, LOGGING_TYPE_OPTIONS, + INVALID_PLATFORMS_REASON, + INVALID_PLATFORMS_FLASH_MESSAGE, } from "utilities/constants"; import usePlatformCompatibility from "hooks/usePlatformCompatibility"; -import { IApiError } from "interfaces/errors"; +import { getErrorReason, IApiError } from "interfaces/errors"; import { ISchedulableQuery, ICreateQueryRequestBody, @@ -328,7 +330,8 @@ const EditQueryForm = ({ renderFlash("success", `Successfully added query.`); }) .catch((createError: { data: IApiError }) => { - if (createError.data.errors[0].reason.includes("already exists")) { + const createErrorReason = getErrorReason(createError); + if (createErrorReason.includes("already exists")) { queryAPI .create({ name: `Copy of ${lastEditedQueryName}`, @@ -351,9 +354,7 @@ const EditQueryForm = ({ }) .catch((createCopyError: { data: IApiError }) => { if ( - createCopyError.data.errors[0].reason.includes( - "already exists" - ) + getErrorReason(createCopyError).includes("already exists") ) { let teamErrorText; if (apiTeamIdForQuery !== 0) { @@ -372,6 +373,9 @@ const EditQueryForm = ({ } setIsSaveAsNewLoading(false); }); + } else if (createErrorReason.includes(INVALID_PLATFORMS_REASON)) { + setIsSaveAsNewLoading(false); + renderFlash("error", INVALID_PLATFORMS_FLASH_MESSAGE); } else { setIsSaveAsNewLoading(false); renderFlash("error", "Could not create query. Please try again."); diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index e16a487145..a3e6490daa 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -424,3 +424,9 @@ export const DEFAULT_USE_QUERY_OPTIONS = { retry: 3, refetchOnWindowFocus: false, }; + +export const INVALID_PLATFORMS_REASON = + "query payload verification: query's platform must be a comma-separated list of 'darwin', 'linux', 'windows', and/or 'chrome' in a single string"; + +export const INVALID_PLATFORMS_FLASH_MESSAGE = + "Couldn't save query. Please update platforms and try again."; From d7821685a868a3c14c8d96e8374231e5d484d2a2 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:25:59 -0700 Subject: [PATCH 13/83] Migrate logic from `nvdtools` into Fleet (#18244) ## Addresses #15999 - [x] Added/updated tests - [ ] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- cmd/cpe/generate.go | 15 +- go.mod | 3 +- go.sum | 92 --- .../vulnerabilities/nvd/cpe_matching_rule.go | 2 +- .../nvd/cpe_matching_rule_test.go | 2 +- .../vulnerabilities/nvd/cpe_matching_rules.go | 2 +- server/vulnerabilities/nvd/cpe_test.go | 2 +- server/vulnerabilities/nvd/cve.go | 10 +- server/vulnerabilities/nvd/cve_test.go | 4 +- server/vulnerabilities/nvd/db.go | 4 +- .../vulnerabilities/nvd/indexed_cpe_item.go | 2 +- server/vulnerabilities/nvd/sync.go | 4 +- server/vulnerabilities/nvd/sync/cve_syncer.go | 6 +- .../nvd/sync/cve_syncer_test.go | 2 +- server/vulnerabilities/nvd/tools/HOWTO.md | 335 ++++++++++ server/vulnerabilities/nvd/tools/LICENSE | 201 ++++++ server/vulnerabilities/nvd/tools/Makefile | 157 +++++ server/vulnerabilities/nvd/tools/README.md | 217 +++++++ .../nvd/tools/cpedict/cpedict.go | 139 +++++ .../nvd/tools/cpedict/cpedict_test.go | 101 +++ .../nvd/tools/cvefeed/cvecache.go | 287 +++++++++ .../nvd/tools/cvefeed/dictionary.go | 105 ++++ .../nvd/tools/cvefeed/eviction_test.go | 123 ++++ .../nvd/tools/cvefeed/evictionqueue.go | 84 +++ .../nvd/tools/cvefeed/evictionqueue_test.go | 84 +++ .../vulnerabilities/nvd/tools/cvefeed/feed.go | 85 +++ .../nvd/tools/cvefeed/matching_json_test.go | 300 +++++++++ .../tools/cvefeed/matching_overrides_test.go | 111 ++++ .../nvd/tools/cvefeed/nvd/match_cpe.go | 141 +++++ .../nvd/tools/cvefeed/nvd/match_cve.go | 176 ++++++ .../nvd/tools/cvefeed/nvd/match_node.go | 69 +++ .../nvd/tools/cvefeed/nvd/schema/schema.go | 255 ++++++++ .../nvd/tools/cvefeed/nvd/smartvercmp.go | 98 +++ .../nvd/tools/cvefeed/nvd/smartvercmp_test.go | 74 +++ .../nvd/tools/cvefeed/stats.go | 154 +++++ .../nvd/tools/cvefeed/stats_test.go | 200 ++++++ .../vulnerabilities/nvd/tools/cvefeed/vuln.go | 80 +++ .../vulnerabilities/nvd/tools/nvdtools.spec | 28 + .../nvd/tools/providers/lib/client/client.go | 66 ++ .../nvd/tools/providers/lib/client/debug.go | 119 ++++ .../nvd/tools/providers/lib/rate/rate.go | 51 ++ .../nvd/tools/providers/nvd/cpe.go | 224 +++++++ .../nvd/tools/providers/nvd/cpe_test.go | 61 ++ .../nvd/tools/providers/nvd/cve.go | 586 ++++++++++++++++++ .../nvd/tools/providers/nvd/cve_test.go | 110 ++++ .../nvd/tools/providers/nvd/doc.go | 21 + .../nvd/tools/providers/nvd/e2e_test.go | 66 ++ .../nvd/tools/providers/nvd/http.go | 63 ++ .../nvd/tools/providers/nvd/http_test.go | 61 ++ .../nvd/tools/providers/nvd/src.go | 60 ++ .../nvd/tools/providers/nvd/sync.go | 72 +++ .../nvd/tools/providers/nvd/xrename.go | 51 ++ server/vulnerabilities/nvd/tools/wfn/doc.go | 18 + server/vulnerabilities/nvd/tools/wfn/fsb.go | 166 +++++ .../vulnerabilities/nvd/tools/wfn/fsb_test.go | 106 ++++ .../vulnerabilities/nvd/tools/wfn/matcher.go | 120 ++++ .../vulnerabilities/nvd/tools/wfn/matching.go | 391 ++++++++++++ .../nvd/tools/wfn/matching_test.go | 185 ++++++ server/vulnerabilities/nvd/tools/wfn/uri.go | 346 +++++++++++ .../vulnerabilities/nvd/tools/wfn/uri_test.go | 110 ++++ server/vulnerabilities/nvd/tools/wfn/wfn.go | 163 +++++ .../vulnerabilities/nvd/tools/wfn/wfn_test.go | 52 ++ 62 files changed, 6901 insertions(+), 121 deletions(-) create mode 100644 server/vulnerabilities/nvd/tools/HOWTO.md create mode 100644 server/vulnerabilities/nvd/tools/LICENSE create mode 100644 server/vulnerabilities/nvd/tools/Makefile create mode 100644 server/vulnerabilities/nvd/tools/README.md create mode 100644 server/vulnerabilities/nvd/tools/cpedict/cpedict.go create mode 100644 server/vulnerabilities/nvd/tools/cpedict/cpedict_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/cvecache.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/dictionary.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/eviction_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/evictionqueue.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/evictionqueue_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/feed.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/matching_json_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/matching_overrides_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cpe.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cve.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/match_node.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/schema/schema.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/stats.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/stats_test.go create mode 100644 server/vulnerabilities/nvd/tools/cvefeed/vuln.go create mode 100644 server/vulnerabilities/nvd/tools/nvdtools.spec create mode 100644 server/vulnerabilities/nvd/tools/providers/lib/client/client.go create mode 100644 server/vulnerabilities/nvd/tools/providers/lib/client/debug.go create mode 100644 server/vulnerabilities/nvd/tools/providers/lib/rate/rate.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/cpe.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/cpe_test.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/cve.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/cve_test.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/doc.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/e2e_test.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/http.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/http_test.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/src.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/sync.go create mode 100644 server/vulnerabilities/nvd/tools/providers/nvd/xrename.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/doc.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/fsb.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/fsb_test.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/matcher.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/matching.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/matching_test.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/uri.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/uri_test.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/wfn.go create mode 100644 server/vulnerabilities/nvd/tools/wfn/wfn_test.go diff --git a/cmd/cpe/generate.go b/cmd/cpe/generate.go index 1f4dcfdf55..076813d2f7 100644 --- a/cmd/cpe/generate.go +++ b/cmd/cpe/generate.go @@ -4,13 +4,6 @@ import ( "compress/gzip" "crypto/sha256" "fmt" - "github.com/facebookincubator/nvdtools/cpedict" - "github.com/facebookincubator/nvdtools/wfn" - "github.com/fleetdm/fleet/v4/pkg/fleethttp" - "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd" - "github.com/pandatix/nvdapi/common" - "github.com/pandatix/nvdapi/v2" "io" "log" "log/slog" @@ -18,6 +11,14 @@ import ( "path/filepath" "strings" "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cpedict" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" + "github.com/pandatix/nvdapi/common" + "github.com/pandatix/nvdapi/v2" ) const ( diff --git a/go.mod b/go.mod index 87e3ac429b..d741cf7e99 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/doug-martin/goqu/v9 v9.18.0 github.com/e-dard/netbug v0.0.0-20151029172837-e64d308a0b20 github.com/elazarl/go-bindata-assetfs v1.0.1 - github.com/facebookincubator/nvdtools v0.1.6-0.20231010102659-d14ce526f176 + github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c github.com/fatih/color v1.15.0 github.com/getsentry/sentry-go v0.18.0 github.com/ghodss/yaml v1.0.0 @@ -205,7 +205,6 @@ require ( github.com/elastic/go-sysinfo v1.7.1 // indirect github.com/elastic/go-windows v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c // indirect github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect diff --git a/go.sum b/go.sum index b42024dcc0..e044f0d205 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,6 @@ 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.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= -cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= -cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= -cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= @@ -51,7 +47,6 @@ 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/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/iam v1.1.2 h1:gacbrBdWcoVmGLozRuStX45YKvJtzIjJdAolzUs1sm4= cloud.google.com/go/iam v1.1.2/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c= @@ -141,13 +136,11 @@ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= @@ -236,7 +229,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -322,8 +314,6 @@ github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= 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= @@ -337,9 +327,7 @@ github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XP github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -351,7 +339,6 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -380,7 +367,6 @@ github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KP github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= @@ -428,16 +414,10 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= -github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c h1:KqlxcP2nuOcMjudCvK0qME2K/aFBDH+xcvYv7HYQaYc= github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c/go.mod h1:QGzNH9ujQ2ZUr/CjDGZGWeDAVStrWNjHeEcjJL96Nuk= -github.com/facebookincubator/nvdtools v0.1.6-0.20231010102659-d14ce526f176 h1:a8y0ludOtb3gZFy8SHcy6xgKEujEd/GeNO1FicC9frg= -github.com/facebookincubator/nvdtools v0.1.6-0.20231010102659-d14ce526f176/go.mod h1:Kh55SAWnjckS96TBSrXI99KrEKH4iB0OJby3N8GRJO4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -670,7 +650,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.4/go.mod h1:AwSRAtLfXpU5 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.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -705,33 +684,25 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFb github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 h1:RtRsiaGvWxcwd8y3BiRZxsylPT8hLWZ5SPcfI+3IDNk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0/go.mod h1:TzP6duP4Py2pHLVPPQp42aoYI92+PCrVotyR5e8Vqlk= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.9.3-0.20191025211905-234833755cb2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0 h1:bkKf0BeBXcSYa7f5Fyi9gMuQ8gNsxeiNpZjR6VxNZeo= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs= github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -748,14 +719,8 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95 h1:S4qyfL2sEm5Budr4KVMyEniCy+PbS55651I/a+Kn/NQ= github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95/go.mod h1:QiyDdbZLaJ/mZP4Zwc9g2QsfaEA4o7XvvgZegSci5/E= github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f h1:es0IoL1/OOoGYUuvRtSzbtG3STd7Fm5LIniUWsfzMHE= @@ -766,7 +731,6 @@ github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= github.com/iancoleman/orderedmap v0.2.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= -github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE= @@ -852,7 +816,6 @@ 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/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/macadmins/osquery-extension v0.0.15 h1:uixbimhzKZSguAcLwKAfi0fieB7gIkxm3saPl9mNl9c= github.com/macadmins/osquery-extension v0.0.15/go.mod h1:gLiR0LcxYjx71EEg70gzV7ah2skWuLw3hwR4eiV+VSw= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -868,9 +831,6 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI= @@ -879,9 +839,7 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -902,12 +860,9 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyex github.com/micromdm/micromdm v1.9.0 h1:FAsIKOpnGcq21UQCrHCUxZwSW4NwBLGOoUtzbURxds8= github.com/micromdm/micromdm v1.9.0/go.mod h1:YsAtsEvfEIwpjYTUPpWkJXSfH0hhp9mMHW1BgIZgRt8= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -980,7 +935,6 @@ github.com/osquery/osquery-go v0.0.0-20230603132358-d2e851b3991b/go.mod h1:OSR0O github.com/pandatix/nvdapi v0.6.4 h1:gix57FcQtOklCUgFrJzJhRblYj+2DN9jxZP6oqtme+A= github.com/pandatix/nvdapi v0.6.4/go.mod h1:DVYxPq0JRERgYzFmwTMknAtH4kB8v9KG+z40JWFRClk= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -1002,13 +956,11 @@ github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942/go.mod h1:eCbImbZ95eXtAUI github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= @@ -1022,7 +974,6 @@ github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= @@ -1032,7 +983,6 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= @@ -1062,7 +1012,6 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= 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= @@ -1107,7 +1056,6 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= @@ -1117,7 +1065,6 @@ github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= -github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= @@ -1176,7 +1123,6 @@ github.com/tklauser/numcpus v0.6.0/go.mod h1:FEZLMke0lhOUG6w2JadTzp0a+Nl8PF/GFkQ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 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= @@ -1234,9 +1180,6 @@ go.elastic.co/fastjson v1.1.0/go.mod h1:boNGISWMjQsUPy/t6yqt2/1Wx4YNPSe+mZjlyw9v go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= -go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak= go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= @@ -1281,7 +1224,6 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= gocloud.dev v0.24.0 h1:cNtHD07zQQiv02OiwwDyVMuHmR7iQt2RLkzoAgz7wBs= @@ -1296,7 +1238,6 @@ golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -1407,13 +1348,11 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -1445,8 +1384,6 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= @@ -1490,8 +1427,6 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1506,7 +1441,6 @@ golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1533,12 +1467,10 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210223095934-7937bea0104d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1551,16 +1483,10 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211102192858-4dd72447c267/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1630,7 +1556,6 @@ golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -1719,10 +1644,6 @@ 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.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= -google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= -google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= -google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= google.golang.org/api v0.128.0 h1:RjPESny5CnQRn9V6siglged+DZCgfu9l6mO9dkX9VOg= google.golang.org/api v0.128.0/go.mod h1:Y611qgqaE92On/7g65MQgxYul3c0rEB894kniWLY750= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= @@ -1795,16 +1716,6 @@ 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-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97 h1:SeZZZx0cP0fqUyA+oRzP9k7cSwJlvDFiROO72uwD6i0= google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97/go.mod h1:t1VqOqqvce95G3hIDCT5FeO3YUc6Q4Oe24L/+rNMxRk= google.golang.org/genproto/googleapis/api v0.0.0-20231012201019-e917dd12ba7a h1:myvhA4is3vrit1a6NZCWBIwN0kNEnX21DJOJX/NvIfI= @@ -1836,8 +1747,6 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= @@ -1872,7 +1781,6 @@ gopkg.in/guregu/null.v3 v3.5.0 h1:xTcasT8ETfMcUHn0zTvIYtQud/9Mx5dJqD554SZct0o= gopkg.in/guregu/null.v3 v3.5.0/go.mod h1:E4tX2Qe3h7QdL+uZ3a0vqvYwKQsRSQKM5V4YltdgH9Y= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= diff --git a/server/vulnerabilities/nvd/cpe_matching_rule.go b/server/vulnerabilities/nvd/cpe_matching_rule.go index 222af5e125..32b331d4f3 100644 --- a/server/vulnerabilities/nvd/cpe_matching_rule.go +++ b/server/vulnerabilities/nvd/cpe_matching_rule.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/Masterminds/semver" - "github.com/facebookincubator/nvdtools/wfn" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" ) // CPEMatchingRuleSpec allows you to match against a CPE. Version ranges are supported via SemVer constraints. diff --git a/server/vulnerabilities/nvd/cpe_matching_rule_test.go b/server/vulnerabilities/nvd/cpe_matching_rule_test.go index c5f3abc1ca..46f4899ce1 100644 --- a/server/vulnerabilities/nvd/cpe_matching_rule_test.go +++ b/server/vulnerabilities/nvd/cpe_matching_rule_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/facebookincubator/nvdtools/wfn" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" "github.com/stretchr/testify/require" ) diff --git a/server/vulnerabilities/nvd/cpe_matching_rules.go b/server/vulnerabilities/nvd/cpe_matching_rules.go index 76ce97b442..f745f97ec0 100644 --- a/server/vulnerabilities/nvd/cpe_matching_rules.go +++ b/server/vulnerabilities/nvd/cpe_matching_rules.go @@ -3,7 +3,7 @@ package nvd import ( "fmt" - "github.com/facebookincubator/nvdtools/wfn" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" ) type CPEMatchingRules []CPEMatchingRule diff --git a/server/vulnerabilities/nvd/cpe_test.go b/server/vulnerabilities/nvd/cpe_test.go index 6f8a5f4c94..bb0b318b18 100644 --- a/server/vulnerabilities/nvd/cpe_test.go +++ b/server/vulnerabilities/nvd/cpe_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/facebookincubator/nvdtools/cpedict" "github.com/fleetdm/fleet/v4/pkg/nettest" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cpedict" kitlog "github.com/go-kit/kit/log" "github.com/go-kit/log" "github.com/stretchr/testify/assert" diff --git a/server/vulnerabilities/nvd/cve.go b/server/vulnerabilities/nvd/cve.go index ff5661cc14..906c3b7485 100644 --- a/server/vulnerabilities/nvd/cve.go +++ b/server/vulnerabilities/nvd/cve.go @@ -15,16 +15,16 @@ import ( "time" "github.com/Masterminds/semver" - "github.com/facebookincubator/nvdtools/cvefeed" - feednvd "github.com/facebookincubator/nvdtools/cvefeed/nvd" - "github.com/facebookincubator/nvdtools/cvefeed/nvd/schema" - "github.com/facebookincubator/nvdtools/providers/nvd" - "github.com/facebookincubator/nvdtools/wfn" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" nvdsync "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/sync" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed" + feednvd "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/providers/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index 8f29cc3e8b..0123d1835d 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -11,11 +11,11 @@ import ( "testing" "time" - "github.com/facebookincubator/nvdtools/cvefeed" - "github.com/facebookincubator/nvdtools/wfn" "github.com/fleetdm/fleet/v4/pkg/nettest" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" "github.com/go-kit/log" kitlog "github.com/go-kit/log" "github.com/stretchr/testify/assert" diff --git a/server/vulnerabilities/nvd/db.go b/server/vulnerabilities/nvd/db.go index 690132171f..b0e0c33b9e 100644 --- a/server/vulnerabilities/nvd/db.go +++ b/server/vulnerabilities/nvd/db.go @@ -6,8 +6,8 @@ import ( "os" "strings" - "github.com/facebookincubator/nvdtools/cpedict" - "github.com/facebookincubator/nvdtools/wfn" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cpedict" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" ) diff --git a/server/vulnerabilities/nvd/indexed_cpe_item.go b/server/vulnerabilities/nvd/indexed_cpe_item.go index 3bc3415027..f2e38086d2 100644 --- a/server/vulnerabilities/nvd/indexed_cpe_item.go +++ b/server/vulnerabilities/nvd/indexed_cpe_item.go @@ -1,8 +1,8 @@ package nvd import ( - "github.com/facebookincubator/nvdtools/wfn" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" ) type IndexedCPEItem struct { diff --git a/server/vulnerabilities/nvd/sync.go b/server/vulnerabilities/nvd/sync.go index 6426303897..a4405b7c9d 100644 --- a/server/vulnerabilities/nvd/sync.go +++ b/server/vulnerabilities/nvd/sync.go @@ -15,12 +15,12 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" - "github.com/facebookincubator/nvdtools/cvefeed" - feednvd "github.com/facebookincubator/nvdtools/cvefeed/nvd" "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/ptr" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed" + feednvd "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd" "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" ) diff --git a/server/vulnerabilities/nvd/sync/cve_syncer.go b/server/vulnerabilities/nvd/sync/cve_syncer.go index 065d8faec8..178429cf65 100644 --- a/server/vulnerabilities/nvd/sync/cve_syncer.go +++ b/server/vulnerabilities/nvd/sync/cve_syncer.go @@ -21,11 +21,11 @@ import ( "strings" "time" - "github.com/facebookincubator/nvdtools/cvefeed/nvd/schema" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/pandatix/nvdapi/common" @@ -36,7 +36,7 @@ import ( // to the directory specified in the dbDir field in the form of JSON files. // It stores the CVE information using the legacy feed format. // The reason we decided to store in the legacy format is because -// the github.com/facebookincubator/nvdtools doesn't yet support parsing +// the github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools doesn't yet support parsing // the new API 2.0 JSON format. type CVE struct { client *http.Client @@ -183,7 +183,7 @@ func (s *CVE) update(ctx context.Context) error { func (s *CVE) updateYearFile(year int, cves []nvdapi.CVEItem) error { // The NVD legacy feed files start at year 2002. - // This is assumed by the facebookincubator/nvdtools package. + // This is assumed by the github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools package. if year < 2002 { year = 2002 } diff --git a/server/vulnerabilities/nvd/sync/cve_syncer_test.go b/server/vulnerabilities/nvd/sync/cve_syncer_test.go index d58e7449c4..b25e0fbefc 100644 --- a/server/vulnerabilities/nvd/sync/cve_syncer_test.go +++ b/server/vulnerabilities/nvd/sync/cve_syncer_test.go @@ -18,7 +18,7 @@ import ( "testing" "time" - "github.com/facebookincubator/nvdtools/cvefeed/nvd/schema" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" "github.com/go-kit/log" "github.com/google/go-cmp/cmp" "github.com/pandatix/nvdapi/v2" diff --git a/server/vulnerabilities/nvd/tools/HOWTO.md b/server/vulnerabilities/nvd/tools/HOWTO.md new file mode 100644 index 0000000000..937f12d565 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/HOWTO.md @@ -0,0 +1,335 @@ +# How to use nvdtools + +The command line tools provided by nvdtools were designed for processing inventory data in pipelines. + +To start, you will need a vulnerability database. In this toolkit you'll find the [nvdsync](https://github.com/facebookincubator/nvdtools/tree/master/cmd/nvdsync) command, which can download the public NVD database to local disk: + +```bash +nvdsync -v=1 -cve_feed=cve-1.0.json.gz /tmp/nvd +``` + +Next up, you need a data collector to create a CPE inventory. Collectors are domain-specific programs capable of acquiring asset information (e.g. a list of hardware, or packages in a repo or system) and printing this information to standard output. + +Think of the simplest data collector as an execution of rpm (or repoquery): + +```bash +rpm -qa | rpm2cpe -rpm=1 -cpe=2 +``` + +This collector [rpm2cpe](https://github.com/facebookincubator/nvdtools/tree/master/cmd/rpm2cpe) will use the name of the rpm files in column 1 of the input, produce a CPE in column 2, and print both to standard output. + +Finally, use the [cpe2cve](https://github.com/facebookincubator/nvdtools/tree/master/cmd/cpe2cve) processor to consume the CPE inventory from standard input and print CVEs affecting which CPEs to standard output: + +```bash +rpm -qa | \ +rpm2cpe -rpm=1 -cpe=2 | \ +cpe2cve -cpe=2 -cve=3 -cwe=4 /tmp/nvd/*.json.gz +``` + +The command above process each CPE individually and prints their respective CVEs. However, it's not uncommon in the NVD database to have more elaborate CVEs which affect a combination of CPEs, e.g. if A and B and not C. For this case, you could group your CPEs per host, for example, and process them in a single batch: + +```bash +set -o pipefail +(hostname +rpm -qa | rpm2cpe -rpm=1 -cpe=2 -e=1 | sort -u | paste -s -d, | \ +cpe2cve -cpe=1 -cve=2 -e=1 /tmp/nvd/*.json.gz | paste -s -d,) | paste -s -d'\t' +``` + +The command above prints a single line containing ` ` for your machine. Great, but is unrealistic to use in each machine in production systems. That's when things start to get more interesting. See the next section for how to decouple this pipeline from collection to processing and reporting. + +# Using nvdtools in production + +In order to effectively use nvdtools, you will likely want to decouple data collection from processing and reporting. + +The idea is to use nvdtools as the building blocks of a much larger system that orchestrates data collection separately from processing, leaving the processing and reporting (heavy lifting) to be executed in a data warehouse. + +Starting from the data collection, think of the different inventory classes that may exist in the environment: + +* Hosted software: packages sitting in software repositories, available to your fleet (source and binary, first-party and third-party) +* Installed software: packages installed on machines or containers, ideally from your managed repositories +* Running software: processes executing on machines or containers, ideally from a known package +* Hardware: a list of hardware parts that can be used to create CPEs, e.g. `cpe:/h:dell:inspiron:8500` + +The collecting stage have different requirements for each class. The processing stage consume inventories from these collector classes and process them with specialized vulnerability databases. + +The public NVD database covers a great deal of open source software and common hardware. However, there are several ecosystems that may be present in your infrastructure (php, python, nodeJS, go) but not well covered by the NVD database alone. + +To maximize vulnerability matching and coverage (and data quality, later user experience on reports), consider using multiple database providers. You will need to convert their databases to the [NVD CVE JSON 1.0](https://csrc.nist.gov/schema/nvd/feed/1.0/nvd_cve_feed_json_1.0.schema) format to use them with the cpe2cve processor. + +Once the data from collectors is decoupled from processing, the nvdtools can be used to process large inventories with millions of assets more efficiently. + +The following sections cover collectors and processors in a bit more depth. + +## Collectors + +This section covers some of the inventory classes mentioned above. + +Collectors are all about retrieving asset information and providing enough data to build CPEs for late processing. + +### Hosted software collectors + +These are domain-specific programs that scrape software repositories (or logs) and report packages available to the fleet. + +Examples of hosted software collectors are programs to report packages hosted in yum, maven, munki, chocolatey, docker registry. + +Data provided by hosted software collectors must contain enough information to create CPEs, comprising at least the asset type (part; a=sw, h=hw, o=os), product and version. Other fields like vendor and target hardware can improve vulnerability matching later, but are not blockers to get started. + +Orchestrating the execution of collectors is platform dependent. At the very least, a cron-like system could periodically run collectors and store their data in files and/or a database. + +Here's an example of cron-like job to scrape all yum repositories configured on the machine running the collector: + +```bash +Q=('{"vendor":"%{VENDOR}","product":"%{NAME}","version":"%{VERSION}","update":"%{RELEASE}","target_hw":"%{ARCH}","metadata":{"product_group":"%{REPO}","package_name":"%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}.rpm","package_source":"%{SOURCERPM}"}}') + +set -o pipefail +repoquery -C --all --queryformat "${Q[@]}" | \ +jq -r '[ "a", .vendor, .product, .version // "-", .update, .sw_edition, .target_sw, .target_hw, ( .metadata | tojson ) ] | @csv' | \ +csv2cpe \ + -cpe_part=1 \ + -cpe_vendor=2 \ + -cpe_product=3 \ + -cpe_version=4 \ + -cpe_update=5 \ + -cpe_swedition=6 \ + -cpe_targetsw=7 \ + -cpe_targethw=8 \ + -e=1 \ + -i=1 \ + -lower \ + -o=$'\t' +``` + +The `-e=1` flag erases the injected "a" part from jq, and the `-i=1` flag tells [csv2cpe](https://github.com/facebookincubator/nvdtools/tree/master/cmd/csv2cpe) to add the cpe in column 1 of its output. + +The tab-separated output contains the following columns: + +``` +cpe, vendor, product, version, update, sw_edition, target_sw, target_hw, metadata_json +``` + +This type of output can be stored in a database such as MySQL by simply adding `mysqlimport` at the end of the pipeline; or write the output to a message queue in similar fashion. + +If executing jq and csv2cpe along with the collector is not an option, you can always store the raw JSON inventory and later execute jq and csv2cpe in the processing stage of the pipeline. + +Notice the metadata field: that information may be helpful much later on the processing and reporting stages, allowing your system to report packages in such a way that your users understand them, avoiding people having to learn the CPE format and details of your system. + +### Installed software collectors + +There are several ways of collecting information about packages installed on a system. We mostly use [osquery](https://osquery.io/) for this, taking periodic snapshots of what is installed on a machine and shipping the data to the data warehouse. + +The main advantage of using osquery is to support all major operating systems with a SQL-like interface for collecting information. + +The osquery results are used to build CPEs which are later processed in batches. + +Here's an example of a query to collect the macOS operating system version and all apps installed: + +```bash +Q=(" +SELECT + 'o' AS part, + 'apple' AS vendor, + os.name AS product, + os.version +FROM + os_version AS os +; +SELECT + 'a' AS part, + '' AS vendor, + bundle_name AS product, + bundle_version AS version +FROM + apps +WHERE + bundle_name IS NOT NULL AND bundle_name <> '' +; +") + +osqueryi --json "${Q[@]}" +``` + +Although osquery supports a `--csv` flag, the JSON output gives flexibility (e.g. handling NULL values) and we can use jq to re-format to CSV, then use csv2cpe to produce the installed software inventory: + +```bash +set -o pipefail +osqueryi --json "${Q[@]}" | \ +jq -r '.[] | [.part, .vendor, .product, .version // "-"] | @csv' | \ +csv2cpe \ + -cpe_part=1 \ + -cpe_vendor=2 \ + -cpe_product=3 \ + -cpe_version=4 \ + -e=1 \ + -i=1 \ + -lower \ + -o=$'\t' +``` + +Converting NULL versions to '-' tells the processor (much later, when cpe2cve is run) to handle dash as "Not Available" during CVE matching, instead of "Any" for empty space. + +RPM packages have richer information, and provide extra metadata for matching CPEs against data from the hosted software collector. Following is a more complex query returning host RPM inventory with metadata: + +```bash +Q=(" +SELECT + 'o' AS part, + 'centos' AS vendor, + 'centos' AS product, + (os.major || '.' || os.minor || '.' || os.patch) AS version, + '' AS release, + sys.cpu_type AS target_hw, + NULL as metadata +FROM + os_version AS os, system_info AS sys +; +SELECT + 'a' AS part, + '' AS vendor, + name AS product, + version, + release, + arch AS target_hw, + JSON_OBJECT( + 'package_name', (name || '-' || version || '-' || release || '.' || arch || '.rpm'), + 'package_source', source, + 'package_sha1', sha1, + 'package_size', size + ) AS metadata +FROM + rpm_packages +; +") + +set -o pipefail +osqueryi --json "${Q[@]}" | \ +jq -r '.[] | [.part, .vendor, .product, .version // "-", .release, .target_hw, .metadata] | @csv' | \ +csv2cpe \ + -cpe_part=1 \ + -cpe_vendor=2 \ + -cpe_product=3 \ + -cpe_version=4 \ + -cpe_update=5 \ + -cpe_targethw=6 \ + -e=1 \ + -i=1 \ + -lower \ + -o=$'\t' +``` + +Similarly to the hosted software collectors, it's up to you to ship raw osquery JSON to a database or message queue, and execute jq and csv2cpe in the processing stage of the pipeline. Also, you'll likely want to record the hostname where the query was executed. Check out the system_info osquery table for details. + +### Running software collectors + +Process information alone is not very useful for vulnerability scanning. Moreover, you have to choose between collecting samples (a snapshot of ps) or hook up into the OS to track all process executions. + +This data is expensive to collect, decorate (enrich with useful information), and move around - can be massive in size. Ask yourself whether this is really needed in your environment. + +Nonetheless, following query is an example for osquery that can capture process information, reporting the RPM package where the binary comes from, along with process-related metadata. + +Note: this query can take a few minutes to run depending on how many processes and packages your system have. + +```bash +Q=(" +SELECT + 'a' AS part, + '' AS vendor, + pkg.name AS product, + pkg.version, + pkg.release, + pkg.arch AS target_hw, + JSON_OBJECT( + 'package_name', (pkg.name || '-' || pkg.version || '-' || pkg.release || '.' || pkg.arch || '.rpm'), + 'package_source', pkg.source, + 'package_sha1', pkg.sha1, + 'package_size', pkg.size, + 'process_name', proc.name, + 'process_parent', proc.parent, + 'process_cwd', proc.cwd, + 'process_cmd', proc.cmdline, + 'process_pid', proc.pid, + 'process_start_time', proc.start_time + ) AS metadata +FROM ( + SELECT * FROM processes WHERE path <> '' +) AS proc +JOIN ( + SELECT * FROM rpm_package_files + WHERE package <> '' AND path <> '' +) AS pkg_files +ON + proc.path = pkg_files.path +JOIN ( + SELECT * FROM rpm_packages WHERE name <> '' +) AS pkg +ON + pkg_files.package = pkg.name +") + +set -o pipefail +osqueryi --json "${Q[@]}" | \ +jq -r '.[] | [.part, .vendor, .product, .version // "-", .release, .target_hw, .metadata] | @csv' | \ +csv2cpe \ + -cpe_part=1 \ + -cpe_vendor=2 \ + -cpe_product=3 \ + -cpe_version=4 \ + -cpe_update=5 \ + -cpe_targethw=6 \ + -e=1 \ + -i=1 \ + -lower \ + -o=$'\t' +``` + +osquery also supports collecting information from docker containers, their networks, and images. This can be useful if you have an inventory of images in a managed registry. + +Metadata can be used later to join against data from the hosted and/or installed software collectors. + +## Processors + +The main processor covered in this section is cpe2cve, the vulnerability matching processor. + +Once collectors are producing data and CPEs are available (or can be built), the cpe2cve processor can perform CVEs matching and produce reports. The output of cpe2cve is always one CVE per line, regardless of whether the input was a single CPE or a group or CPEs. + +Given the different inventories, you may want different vulnerability databases to process them. As previously mentioned, the public NVD database alone is generally not enough for good coverage. Specialized ecosystems (e.g. nodejs, ruby, python, php, go) require specialized databases. + +### Vulnerability Databases + +It is recommended to use multi-vendor databases. The cpe2cve processor require databases in the NVD CVE JSON 1.0 format, as files on disk. XML is also supported but discouraged, and likely to be deprecated - XML databases don't support the concept of version ranges, resulting in lower quality CVE matching and reporting. + +On a system with multi-vendor databases, the maintainers of collectors should be able to define which database(s) to use to process their inventory. For example, the yum collector maintainer would pick the NVD database, but the nodejs collector maintainer would prefer a specialized database, e.g. snyk. + +### Vulnerability Database: patches, snoozes, edits + +It's not uncommon for processors to report false positives due to the quality of the inventory and databases, lack of normalization (missing vendors, wrong product names, bad versions). + +Curating the data is the hardest part of maintaining a large system with multiple inventories and databases. Reporting high quality data is generally what makes the system successful. + +Following are some methods that can help improve the data quality and end-user experience: + +* Patch reports: allow the collectors to report patches applied to their source code; this can avoid reporting false positives by effectively `grep -v`'ing a list of patched CVEs from the processor output +* Snoozes: let users snooze certain CVEs, in the sense of not reporting them for a period of time or indefinitely; this can avoid reporting false positives consecutively +* Edits: some times the quality of the vulnerability database is subpar, missing information, or containing incorrect information; allowing edits to existing CVEs or creating new CVEs can improve the quality of matching and reports + +### The [cpe2cve](https://github.com/facebookincubator/nvdtools/blob/master/cmd/cpe2cve) vulnerability processor + +Using the cpe2cve vulnerability processor is pretty straightforward, but it's worth highlighting a few things: + +* The quality of the vulnerability matching (CVE) results depend entirely on the quality of the CPE inventory and the vulnerability database being used (garbage in -> garbage out) +* Using specialized vulnerability databases for specific inventories can increase the quality of the results +* Processing one CPE alone may not yield all vulnerabilities; CVE databases use conditional logic (expressions) to match CPEs: if A and B or C +* Processing inventories from hosted software collectors generally process each CPE individually and does not account for their dependencies; putting that data together is not part of nvdtools +* Processing inventories from installed or running software collectors yield better results when all CPEs are grouped and processed in one batch; ideally with an operating system CPE (cpe:/o) in addition to all packages (cpe:/a) +* Consider whether you really need to process inventories from installed and running software using cpe2cve: this can be expensive depending on the size of your fleet; you may want to start by simply matching CPEs back to your hosted software inventories +* Use patching information, snoozes, and edits on top of the CVE database - in pre or post processing stages - to avoid false positives and consecutive false positives resulting in anger and disappointment from your users +* Use the metadata from collectors to build high quality reports for their maintainers, present data that know about (their own package names, not CPEs) + +All the collector examples in previous sections put their generated CPE (or comma-separated list of CPEs) in the first column of their output. Their output has tab-separated columns. Those are also the default delimiters for the cpe2cve input (check --help). + +With the examples above, an execution of the CVE processor could take CPE(s) from column 1 of the input, and insert CVE in the same column, pushing the original input one column forward: + +```bash +cat inventory.csv | cpe2cve -cpe=1 -cve=1 /tmp/nvd/*.json.gz +``` + +Check out the [--help](https://github.com/facebookincubator/nvdtools/blob/master/cmd/cpe2cve/cpe2cve.go#L51) flag for all options related to input and output delimiters, lists, caching, and extra columns you may want to add to the output, such as CVSS score and CWE of each CVE. diff --git a/server/vulnerabilities/nvd/tools/LICENSE b/server/vulnerabilities/nvd/tools/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/server/vulnerabilities/nvd/tools/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/vulnerabilities/nvd/tools/Makefile b/server/vulnerabilities/nvd/tools/Makefile new file mode 100644 index 0000000000..75a5e0dc06 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/Makefile @@ -0,0 +1,157 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +NAME = nvdtools +VERSION = tip + +TOOLS = \ + cpe2cve \ + csv2cpe \ + fireeye2nvd \ + flexera2nvd \ + idefense2nvd \ + nvdsync \ + rpm2cpe \ + rustsec2nvd \ + snyk2nvd \ + vulndb + +DOCS = \ + CODE_OF_CONDUCT.md \ + CONTRIBUTING.md \ + HOWTO.md \ + LICENSE \ + README.md + +GO = go +GOOS = $(shell $(GO) env GOOS) +GOARCH = $(shell $(GO) env GOARCH) + +TAR = tar +ZIP = zip +INSTALL = install + +# Compile all tools. +all: $(TOOLS) + +# Compile TOOLS to ./build/bin/$tool using GOOS and GOARCH. +$(TOOLS): + GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO) build $(GOFLAGS) -o ./build/bin/$@ ./cmd/$@ + +# Check/fetch all dependencies. +deps: + GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO) get -v -d ./... + +# install installs tools and documentation. +# The install target is used by rpm and deb builders. +install: + # tools + $(INSTALL) -d $(DESTDIR)/usr/bin + for tool in $(TOOLS); do $(INSTALL) -p -m 0755 ./build/bin/$$tool $(DESTDIR)/usr/bin/$$tool; done + # docs + $(INSTALL) -d $(DESTDIR)/usr/share/doc/nvdtools + for doc in $(DOCS); do $(INSTALL) -p -m 0644 $$doc $(DESTDIR)/usr/share/doc/nvdtools/$$doc; done + +DIST_NAME = $(NAME)-$(VERSION) +DIST_DIR = build/$(DIST_NAME) + +# binary_dist creates a local binary distribution in DIST_DIR. +binary_dist: $(TOOLS) + mkdir -p $(DIST_DIR)/doc + cp $(DOCS) $(DIST_DIR)/doc + mv build/bin $(DIST_DIR)/bin + +# binary_tar creates tarball of binary distribution. +binary_tar: binary_dist + mkdir -p build/tgz + cd build && $(TAR) czf tgz/$(DIST_NAME)-$(GOOS)-$(GOARCH).tar.gz $(DIST_NAME) + rm -rf $(DIST_DIR) + +# binary_zip creates zip of binary distribution. +binary_zip: binary_dist + mkdir -p build/zip + cd build && $(ZIP) -r zip/$(DIST_NAME)-$(GOOS)-$(GOARCH).zip $(DIST_NAME) + rm -rf $(DIST_DIR) + +# binary_deb creates debian package. +# +# Requires GOPATH and dependencies available to compile nvdtools. +# Must set version to build: make binary_deb VERSION=1.0 +binary_deb: + VERSION=$(VERSION) dpkg-buildpackage -rfakeroot -uc -us + mkdir -p build/deb + mv ../$(NAME)*.deb build/deb/ + +# archive_tar creates tarball of the source code. +archive_tar: + mkdir -p build/tgz + $(TAR) czf build/tgz/$(DIST_NAME).tar.gz \ + --exclude=build \ + --exclude=release \ + --exclude=.git \ + --exclude=.travis.yml \ + --transform s/./$(DIST_NAME)/ \ + . + +# binary_rpm creates rpm package. +# +# Requires GOPATH and dependencies available to compile nvdtools. +# Must set version to build: make binary_rpm VERSION=1.0 +binary_rpm: archive_tar + mkdir -p build/rpm/SOURCES + mv build/tgz/$(DIST_NAME).tar.gz build/rpm/SOURCES/ + rpmbuild -ba \ + --define="_topdir $(PWD)/build/rpm" \ + --define="_version $(VERSION)" \ + nvdtools.spec + +# release_tar creates tarball releases. +release_tar: + mkdir -p release + make deps binary_tar GOOS=darwin GOARCH=amd64 + make deps binary_tar GOOS=freebsd GOARCH=amd64 + make deps binary_tar GOOS=freebsd GOARCH=arm + make deps binary_tar GOOS=linux GOARCH=amd64 + make deps binary_tar GOOS=linux GOARCH=arm64 + mv build/tgz/*.tar.gz release + +# release_zip creates zip releases. +release_zip: + mkdir -p release + make deps binary_zip GOOS=windows GOARCH=386 + make deps binary_zip GOOS=windows GOARCH=amd64 + mv build/zip/*.zip release + +# release_deb creates debian releases. +release_deb: binary_deb + mkdir -p release + mv build/deb/*.deb release + +# release_rpm creates rpm releases. +release_rpm: binary_rpm + mkdir -p release + mv build/rpm/RPMS/*/*.rpm release + +# release creates all release packages. +# Example: make distclean release VERSION=1.0 +release: release_deb release_rpm release_tar release_zip + +# Removes build related files. +clean: + rm -rf build + +distclean: clean + rm -rf release + +.PHONY: $(TOOLS) diff --git a/server/vulnerabilities/nvd/tools/README.md b/server/vulnerabilities/nvd/tools/README.md new file mode 100644 index 0000000000..d5685d52c0 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/README.md @@ -0,0 +1,217 @@ +> The contents of this directory were copied (in April 2024) from https://github.com/facebookincubator/nvdtools.git. +--- + +![Tests](https://github.com/facebookincubator/nvdtools/actions/workflows/tests.yaml/badge.svg) + +# NVD Tools + +A collection of tools for working with [National Vulnerability Database](https://nvd.nist.gov/) feeds. + +The [HOWTO](HOWTO.md) provides a broader view on how to effectively use these tools. + +--- + +* [Requirements](#requirements) +* [Installation](#installation) +* [How build](#How-build) +* [Command line tools](#command-line-tools) + * [cpe2cve](#cpe2cve) + * [csv2cpe](#cpe2cve) + * [fireeye2nvd](#fireeye2nvd) + * [flexera2nvd](#flexera2nvd) + * [idefense2nvd](#idefense2nvd) + * [nvdsync](#nvdsync) + * [rpm2cpe](#rpm2cpe) + * [rustsec2nvd](#rustsec2nvd) + * [vfeed2nvd](#vfeed2nvd) + * [vulndb](#vulndb) +* [Libraries](#libraries) + * [cvss2](#cvss2) + * [cvss3](#cvss3) + * [wfn](#wfn) +* [License](#license) + +--- + +## Requirements + +* Go 1.13 or newer + +## Installation + +You need a properly setup Go environment. + +#### Download and install NVD Tools: + +For Go 1.13 - 1.14: +```bash +go get github.com/facebookincubator/nvdtools/... +cd "$GOPATH"/src/github.com/facebookincubator/nvdtools/cmd +go install ./... +``` + +From Go 1.15 onwards, modules are not downloaded to `GOPATH`, but to `GOMODCACHE`. It is recommended to clone the repo and run run go install from there instead: +```bash +git clone https://github.com/facebookincubator/nvdtools +cd nvdtools +go install ./... +``` + +From Go 1.17 onwards, `go get` is deprecated. `go install` is used instead to download the module to the cache and install it: +```bash +go install github.com/facebookincubator/nvdtools/...@latest +``` + +## How-build +```bash +go mod init github.com/facebookincubator/nvdtools +go mod tidy +make +cp build/bin/* ~/go/bin/ + +``` + +## Command line tools + +### `cpe2cve` + +*cpe2cve* is a command line tool for scanning an inventory of CPE names for vulnerabilities. + +It expects a stream of lines of delimiter-separated fields, one of these fields being a delimiter-separated list of CPE names in the inventory. + +Vulnerability feeds should be provided as arguments to the program in JSON format. + +Output is a stream of delimiter-separated input value decorated with a vulnerability ID (CVE) and a delimiter-separated list of CPE names that match this vulnerability. + +Unwanted input fields could be erased from the output with `-e` option. + +Input and output delimiters can be configured with `-d`, `-d2`, `-o` an `-o2` options. + +The column to which output the CVE and matches for that CVE can be configured with `-cve` and `-matches` options correspondingly. + +### download data +```bash +curl -o- -s -k -v https://nvd.nist.gov/vuln/data-feeds >data-feeds.html +cat data-feeds.html|grep -Eo '(/feeds\/[^"]*\.gz)'|xargs -I % wget -c https://nvd.nist.gov% +``` + +#### Example 1: scan a software for vulnerabilities + +```bash +echo "cpe:/a:apache"|cpe2cve -cpe 1 -e 1 -cve 1 nvdcve-1.1-*.json.gz +echo "cpe:/a:gnu:glibc:2.28" | cpe2cve -cpe 1 -e 1 -cve 1 nvdcve-1.0-*.json.gz +CVE-2009-4881 +CVE-2015-8985 +CVE-2016-4429 +CVE-2010-3192 +CVE-2010-4756 +``` + +#### Example 2: find vulnerabilities in software inventory per production host + +```bash +./cpe2cve -d ' ' -d2 , -o ' ' -o2 , -cpe 2 -e 2 -matches 3 -cve 2 nvdcve-1.0-*.json.gz << EOF +host1.foo.bar cpe:/a:gnu:glibc:2.28,cpe:/a:gnu:zlib:1.2.8 +host2.foo.bar cpe:/a:gnu:glibc:2.28,cpe:/a:haxx:curl:7.55.0 +EOF +host1.foo.bar CVE-2009-4881 cpe:/a:gnu:glibc:2.28 +host1.foo.bar CVE-2016-4429 cpe:/a:gnu:glibc:2.28 +host2.foo.bar CVE-2014-5119 cpe:/a:gnu:glibc:2.28 +host2.foo.bar CVE-2016-4429 cpe:/a:gnu:glibc:2.28 +host2.foo.bar CVE-2018-1000120 cpe:/a:haxx:curl:7.55.0 +host2.foo.bar CVE-2018-1000122 cpe:/a:haxx:curl:7.55.0 +host2.foo.bar CVE-2010-4756 cpe:/a:gnu:glibc:2.28 +host2.foo.bar CVE-2017-8817 cpe:/a:haxx:curl:7.55.0 +``` + +### `csv2cpe` + +*csv2cpe* is a tool that generates an URI-bound CPE from CSV input, flags configure the meaning of each input field: + +* `-cpe_part` -- identifies the class of a product: h for hardware, a for application and o for OS +* `-cpe_vendor` -- identifies the person or organisation that manufactured or created the product +* `-cpe_product` -- describes or identifies the most common and recognisable title or name of the product +* `-cpe_version` -- vendor-specific alphanumeric strings characterising the particular release version of the product +* `-cpe_update` -- vendor-specific alphanumeric strings characterising the particular update, service pack, or point release of the product +* `-cpe_edition` -- capture edition-related terms applied by the vendor to the product; this attribute is considered deprecated in CPE specification version 2.3 and it should be assigned the logical value ANY except where required for backward compatibility with version 2.2 of the CPE specification. +* `-cpe_swedition` -- characterises how the product is tailored to a particular market or class of end users +* `-cpe_targetsw` -- characterises the software computing environment within which the product operates +* `-cpe_targethw` -- characterises the software computing environment within which the product operates +* `-cpe_language` -- defines the language supported in the user interface of the product being described; must be valid language tags as defined by [RFC5646] +* `-cpe_other` -- any other general descriptive or identifying information which is vendor- or product-specific and which does not logically fit in any other attribute value + +Omitted parts of the CPE name defaults to logical value ANY, as per [specification](https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf) + +Optional flag `-lower` brings the strings to lower case. + +#### Example: generate URI-bound CPE name out of comma-separated list of attributes + +```bash +$ echo 'a,Microsoft,Internet Explorer,8.1,SP1,-,*' | csv2cpe -x -lower -cpe_part=1 -cpe_vendor=2 -cpe_product=3 -cpe_version=4 -cpe_update=5 -cpe_edition=6 -cpe_language=7 +cpe:/a:microsoft:internet_explorer:8.1:sp1:- +``` + +### `fireeye2nvd` + +*fireeye2nvd* downloads the vulnerability data from [FireEye](https://www.fireeye.com/) and converts it into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `flexera2nvd` + +*flexera2nvd* downloads the vulnerability data from [Flexera](https://www.flexera.com/) and converts it into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `idefense2nvd` + +*idefense2nvd* downloads the vulnerability data from Idefense and converts it into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `nvdsync` + +*nvdsync* synchronizes NVD data feeds to local directory; it checks the hashes of the files against the ones provided by NVD and only updates the changed files. + +### `rpm2cpe` + +*rpm2cpe* takes a delimiter-separated input with one of the fields containing RPM package name and produces delimiter-separated output consisting of the same fields plus CPE name parsed from RPM package name. + +#### Example: generate URI-bound CPE name out of RPM package filename + +```bash +echo openoffice-eu-writer-4.1.5-9789.i586.rpm | rpm2cpe -rpm=1 -cpe=2 -e=1 +cpe:/a::openoffice-eu-writer:4.1.5:9789:~~~~i586~ +``` + +### `rustsec2nvd` + +*rustsec2nvd* converts the vulnerabilities from the [Rustsec Advisory-DB](https://github.com/RustSec/advisory-db) into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `snyk2nvd` + +*snyk2nvd* downloads the vulnerability data from [Snyk](https://snyk.io/) and converts it into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `vfeed2nvd` + +*vfeed2nvd* converts the vulnerability data from [vFeed](https://vfeed.io/) into NVD format. The resulting file can be used as a feed in [`cpe2cve`](#cpe2cve) processor + +### `vulndb` + +*vulndb* is a command line tool to manage NVD-like vulnerability databases, backed by MySQL. + +Supports NVD CVE JSON 1.0 feeds. Data is versioned, organized by provider names and grouped by vendor, custom, and snoozes datasets: + +* Vendor dataset: read-only CVE feeds we continuously import. +* Custom dataset: allows to overwrite CVEs from vendor data with custom data during exports +* Snooze dataset: user-defined CVE and metadata with deadline, used for remediation automation + +See `vulndb help` for details. + +## Libraries + +### cvss2 + +Implementation of [CVSS v2 specification](https://www.first.org/cvss/v2/guide) which provides functions for serializing and deserializing vectors as well as score calculation. + +### cvss3 + +Implementation of [CVSS v3 specification](https://www.first.org/cvss/specification-document) which provides functions for serializing and deserializing vectors as well as score calculation. + +## License + +nvdtools licensed under Apache License, Version 2.0, as found in the [LICENSE](LICENSE) file. diff --git a/server/vulnerabilities/nvd/tools/cpedict/cpedict.go b/server/vulnerabilities/nvd/tools/cpedict/cpedict.go new file mode 100644 index 0000000000..3bb45eee4e --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cpedict/cpedict.go @@ -0,0 +1,139 @@ +// Package cpedict defines the types and methods necessary to parse and lookup CPE dictionary conforming to +// CPE Dictionary specification 2.3 as per https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7697.pdf. +// The implementation is not full, only parts required to parse NVD vulnerability feed are implemented +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package cpedict + +import ( + "encoding/xml" + "io" + "time" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +// TextType represents multi-language text +type TextType map[string]string + +// UnmarshalXML -- load TextType from XML +func (t *TextType) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var text string + lang := "en" + if *t == nil { + *t = TextType{} + } + for _, attr := range start.Attr { + if attr.Name.Local == "lang" { + lang = attr.Value + } + } + if err := d.DecodeElement(&text, &start); err != nil { + return err + } + (*t)[lang] = text + return nil +} + +// PlatformType -- NVD doesn't use it +// TODO: implement +// type PlatformType struct{} + +// CheckFactRefType is a reference to a check that always evaluates to +// TRUE, FALSE, or ERROR. Examples of types of checks are OVAL and OCIL checks. +// NVD doesn't use it +// TODO: implement +// type CheckFactRefType struct{} + +// NamePattern represents CPE name +type NamePattern wfn.Attributes + +// UnmarshalXMLAttr implements xml.UnmarshalerAttr interface +func (np *NamePattern) UnmarshalXMLAttr(attr xml.Attr) error { + wfn, err := wfn.Parse(attr.Value) + if err != nil { + return err + } + *np = (NamePattern)(*wfn) + return nil +} + +func (np NamePattern) String() string { + return wfn.Attributes(np).String() +} + +// Reference holds additional information about CPE. +type Reference struct { + URL string `xml:"href,attr"` + Desc string `xml:",chardata"` +} + +// DeprecatedInfo contains the name that is deprecating the identifier name and the type of Deprecation +type DeprecatedInfo struct { + Name NamePattern `xml:"name,attr"` + Type string `xml:"type,attr"` +} + +// Deprecation contains the deprecation information for a specific deprecation of a given identifier name. +type Deprecation struct { + Date time.Time `xml:"date,attr"` + DeprecatedBy []DeprecatedInfo `xml:"deprecated-by"` +} + +// CPE23Item contains all CPE 2.3 specific data related to a given identifier name. +type CPE23Item struct { + Name NamePattern `xml:"name,attr"` + Deprecation *Deprecation `xml:"deprecation"` + // TODO: implement ProvenanceRecord +} + +// CPEItem contains all of the information for a single dictionary entry (identifier name), including metadata. +type CPEItem struct { + Name NamePattern `xml:"name,attr"` + Deprecated bool `xml:"deprecated,attr"` + DeprecatedBy *NamePattern `xml:"deprecated_by,attr"` + DeprecationDate time.Time `xml:"deprecation_date,attr"` + CPE23 CPE23Item `xml:"cpe23-item"` + Title TextType `xml:"title"` + Notes TextType `xml:"notes"` + References []Reference `xml:"references>reference"` + // Calls out a check, such as an OVAL definition, that can confirm or reject + // an IT system as an instance of the named platform. 0-n occurrences. + // TODO: not implemented + Check struct{} `xml:"check"` +} + +// Generator contains information about the generation of the dictionary file. +type Generator struct { + ProductName string `xml:"product_name"` + ProductVersion string `xml:"product_version"` + SchemaVersion string `xml:"schema_version"` + TimeStamp time.Time `xml:"timestamp"` +} + +// CPEList contains all of the dictionary entries and dictionary metadata. +type CPEList struct { + Generator Generator `xml:"generator"` + Items []CPEItem `xml:"cpe-item"` +} + +// Decode decodes dictionary XML +func Decode(r io.Reader) (*CPEList, error) { + var list CPEList + if err := xml.NewDecoder(r).Decode(&list); err != nil { + return nil, err + } + return &list, nil +} diff --git a/server/vulnerabilities/nvd/tools/cpedict/cpedict_test.go b/server/vulnerabilities/nvd/tools/cpedict/cpedict_test.go new file mode 100644 index 0000000000..3d5f6af345 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cpedict/cpedict_test.go @@ -0,0 +1,101 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cpedict + +import ( + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +func TestDecode(t *testing.T) { + xmlStr := ` + + + + National Vulnerability Database (NVD) + 3.20 + 2.3 + 2018-04-25T03:50:11.922Z + + + $0.99 Kindle Books project $0.99 Kindle Books (aka com.kindle.books.for99) for android 6.0 + + Product information + Government Advisory + + + + + アドビシステムズ Flex + Adobe Flex + + + + 3Com TippingPoint IPS TOS 2.1.3.6323 + + + + + + + +` + data, err := Decode(strings.NewReader(xmlStr)) + if err != nil { + t.Fatalf("failed to decode xml: %v", err) + } + + gentm, _ := time.Parse(time.RFC3339, "2018-04-25T03:50:11.922Z") + generator := Generator{"National Vulnerability Database (NVD)", "3.20", "2.3", gentm} + if data.Generator != generator { + t.Errorf("bad generator:\n\texpected %+v\n\tgot %+v", data.Generator, generator) + } + + wfname, _ := wfn.Parse("cpe:/a:%240.99_kindle_books_project:%240.99_kindle_books:6::~~~android~~") + item := data.Items[0] + if item.Name != item.CPE23.Name || item.Name != NamePattern(*wfname) { + t.Errorf("bad CPE name:\n\t2.2 is %+v\n\t2.3 is %+v", item.Name, item.CPE23.Name) + } + if len(item.References) != 2 { + t.Errorf("item was expected to have 2 references, %d found\n\t%v", len(item.References), item) + } + + wfname, _ = wfn.Parse("cpe:2.3:o:3com:tippingpoint_ips_tos:2.1.3.6323:*:*:*:*:*:*:*") + name := NamePattern(*wfname) + deptm, _ := time.Parse(time.RFC3339, "2010-12-28T17:35:59.740Z") + item = data.Items[len(data.Items)-1] + if !item.Deprecated { + t.Errorf("item was expected to be deprecated, but isn't:\n\t%+v", item) + } + if !item.DeprecationDate.Equal(deptm) { + t.Errorf("item's deprecation time was expected to be\n\t%v\ngot\n\t%v", deptm, item.DeprecationDate) + } + if item.CPE23.Deprecation == nil { + t.Fatal("item was expected to have Deprecation info, but it doesn't") + } + dep := item.CPE23.Deprecation + if !dep.Date.Equal(item.DeprecationDate) { + t.Errorf("cpe23 deprecation date doesn't match the cpe22 one:\n\t%v\n\t%v", dep.Date, item.DeprecationDate) + } + if dep.DeprecatedBy[0].Name != name { + t.Errorf("item was expected to be deprecated by\n\t%v\n\tgot %v", dep.DeprecatedBy[0].Name, name) + } + if dep.DeprecatedBy[0].Type != "NAME_CORRECTION" { + t.Errorf("item was expected to be deprecated because of NAME_CORRECTION, got %v", dep.DeprecatedBy[0].Type) + } +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/cvecache.go b/server/vulnerabilities/nvd/tools/cvefeed/cvecache.go new file mode 100644 index 0000000000..035bd94590 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/cvecache.go @@ -0,0 +1,287 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "sort" + "strings" + "sync" + "sync/atomic" + "unsafe" + + "github.com/facebookincubator/flog" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +const cacheEvictPercentage = 0.1 // every eviction cycle invalidates this part of cache size at once + +// Index maps the CPEs to the entries in the NVD feed they mentioned in +type Index map[string][]Vuln + +// NewIndex creates new Index from a slice of CVE entries +func NewIndex(d Dictionary) Index { + idx := Index{} + for _, entry := range d { + set := map[string]bool{} + for _, cpe := range entry.Config() { + // Can happen, for instance, when the feed contains illegal binding of CPE name. Unfortunately, it happens to NVD, + // e.g. embedded ? in cpe:2.3:a:disney:where\\'s_my_perry?_free:1.5.1:*:*:*:*:android:*:* of CVE-2014-5606 + if cpe == nil { + continue + } + product := cpe.Product + if wfn.HasWildcard(product) { + product = wfn.Any + } + if !set[product] { + set[product] = true + idx[product] = append(idx[product], entry) + } + } + } + return idx +} + +// MatchResult stores CVE and a slice of CPEs that matched it +type MatchResult struct { + CVE Vuln + CPEs []*wfn.Attributes +} + +// cachedCVEs stores cached CVEs, a channel to signal if the value is ready +type cachedCVEs struct { + res []MatchResult + ready chan struct{} + size int64 + evictionIndex int // position in eviction queue +} + +// updateResSize calculates the size of cached MatchResult and assigns it to cves.size +func (cves *cachedCVEs) updateResSize(key string) { + if cves == nil { + return + } + cves.size = int64(int(unsafe.Sizeof(key)) + len(key)) + cves.size += int64(unsafe.Sizeof(cves.res)) + for i := range cves.res { + cves.size += int64(unsafe.Sizeof(cves.res[i].CVE)) + for _, attr := range cves.res[i].CPEs { + cves.size += int64(len(attr.Part)) + int64(unsafe.Sizeof(attr.Part)) + cves.size += int64(len(attr.Vendor)) + int64(unsafe.Sizeof(attr.Vendor)) + cves.size += int64(len(attr.Product)) + int64(unsafe.Sizeof(attr.Product)) + cves.size += int64(len(attr.Version)) + int64(unsafe.Sizeof(attr.Version)) + cves.size += int64(len(attr.Update)) + int64(unsafe.Sizeof(attr.Update)) + cves.size += int64(len(attr.Edition)) + int64(unsafe.Sizeof(attr.Edition)) + cves.size += int64(len(attr.SWEdition)) + int64(unsafe.Sizeof(attr.SWEdition)) + cves.size += int64(len(attr.TargetHW)) + int64(unsafe.Sizeof(attr.TargetHW)) + cves.size += int64(len(attr.Other)) + int64(unsafe.Sizeof(attr.Other)) + cves.size += int64(len(attr.Language)) + int64(unsafe.Sizeof(attr.Language)) + } + } +} + +// Cache caches CVEs for known CPEs +type Cache struct { + // Used to compute the hit ratio + numLookups int64 + numHits int64 + + // Actual cache data + data map[string]*cachedCVEs + evictionQ *evictionQueue + mu sync.Mutex + Dict Dictionary + Idx Index + MaxSize int64 // maximum size of the cache, 0 -- unlimited, -1 -- no caching + size int64 // current size of the cache + RequireVersion bool // ignore matching specifications that have Version == ANY +} + +// NewCache creates new Cache instance with dictionary dict. +func NewCache(dict Dictionary) *Cache { + return &Cache{Dict: dict, evictionQ: new(evictionQueue)} +} + +// SetRequireVersion sets if the instance of cache fails matching the dictionary +// records without Version attribute of CPE name. +// Returns a pointer to the instance of Cache, for easy chaining. +func (c *Cache) SetRequireVersion(requireVersion bool) *Cache { + c.RequireVersion = requireVersion + return c +} + +// SetMaxSize sets maximum size of the cache to some pre-defined value, +// size of 0 disables eviction (makes the cache grow indefinitely), +// negative size disables caching. +// Returns a pointer to the instance of Cache, for easy chaining. +func (c *Cache) SetMaxSize(size int64) *Cache { + c.MaxSize = size + return c +} + +// Get returns slice of CVEs for CPE names from cpes parameter; +// if CVEs aren't cached (and the feature is enabled) it finds them in cveDict and caches the results +func (c *Cache) Get(cpes []*wfn.Attributes) []MatchResult { + atomic.AddInt64(&c.numLookups, 1) + + // negative max size of the cache disables caching + if c.MaxSize < 0 { + return c.match(cpes) + } + + // otherwise, let's get to the business + key := cacheKey(cpes) + c.mu.Lock() + if c.data == nil { + c.data = make(map[string]*cachedCVEs) + } + cves := c.data[key] + if cves != nil { + atomic.AddInt64(&c.numHits, 1) + + // value is being computed, wait till ready + c.mu.Unlock() + <-cves.ready + c.mu.Lock() // TODO: XXX: ugly, consider using atomic.Value instead + cves.evictionIndex = c.evictionQ.touch(cves.evictionIndex) + c.mu.Unlock() + return cves.res + } + // first request; the goroutine that sent it computes the value + cves = &cachedCVEs{ready: make(chan struct{})} + c.data[key] = cves + c.mu.Unlock() + // now other requests for same key wait on the channel, and the requests for the different keys aren't blocked + cves.res = c.match(cpes) + cves.updateResSize(key) + c.mu.Lock() + if c.MaxSize != 0 && c.size+cves.size > c.MaxSize { + c.evict(int64(cacheEvictPercentage*float64(c.MaxSize)) + cves.size) + } + c.size += cves.size + cves.evictionIndex = c.evictionQ.push(key) + c.mu.Unlock() + close(cves.ready) + return cves.res +} + +// match will return all match results based on the given cpes +func (c *Cache) match(cpes []*wfn.Attributes) []MatchResult { + d := c.Dict + if c.Idx != nil { + d = c.dictFromIndex(cpes) + } + return c.matchDict(cpes, d) +} + +// dictFromIndex creates CVE dictionary from entries indexed by CPE names +func (c *Cache) dictFromIndex(cpes []*wfn.Attributes) Dictionary { + d := Dictionary{} + if c.Idx == nil { + return d + } + + knownEntries := map[Vuln]bool{} + addVulns := func(product string) { + for _, vuln := range c.Idx[product] { + if !knownEntries[vuln] { + knownEntries[vuln] = true + d[vuln.ID()] = vuln + } + } + } + + for _, cpe := range cpes { + if cpe == nil { // should never happen + flog.Warning("nil CPE in list") + continue + } + // any of the CPEs having product=ANY would mean we need to match against the entire dictionary + if cpe.Product == wfn.Any { + return c.Dict + } + addVulns(cpe.Product) + } + addVulns(wfn.Any) + + return d +} + +// match matches the CPE names against internal vulnerability dictionary and returns a slice of matching resutls +func (c *Cache) matchDict(cpes []*wfn.Attributes, dict Dictionary) (results []MatchResult) { + for _, v := range dict { + if matches := v.Match(cpes, c.RequireVersion); len(matches) > 0 { + results = append(results, MatchResult{v, matches}) + } + } + return results +} + +// evict the least recently used records untile nbytes of capacity is achieved or no more records left. +// It is not concurrency-safe, c.mu should be locked before calling it. +func (c *Cache) evict(nbytes int64) { + for c.size > 0 && c.size+nbytes > c.MaxSize { + key := c.evictionQ.pop() + cd, ok := c.data[key] + if !ok { // should not happen + panic("attempted to evict non-existent record") + } + c.size -= cd.size + delete(c.data, key) + } +} + +func cacheKey(cpes []*wfn.Attributes) string { + parts := make([]string, 0, len(cpes)) + for _, cpe := range cpes { + if cpe == nil { + continue + } + var out strings.Builder + out.WriteString(cpe.Part) + out.WriteByte('^') + out.WriteString(cpe.Vendor) + out.WriteByte('^') + out.WriteString(cpe.Product) + out.WriteByte('^') + out.WriteString(cpe.Version) + out.WriteByte('^') + out.WriteString(cpe.Update) + out.WriteByte('^') + out.WriteString(cpe.Edition) + out.WriteByte('^') + out.WriteString(cpe.SWEdition) + out.WriteByte('^') + out.WriteString(cpe.TargetSW) + out.WriteByte('^') + out.WriteString(cpe.TargetHW) + out.WriteByte('^') + out.WriteString(cpe.Other) + out.WriteByte('^') + out.WriteString(cpe.Language) + parts = append(parts, out.String()) + } + sort.Strings(parts) + return strings.Join(parts, "#") +} + +// HitRatio returns the cache hit ratio, the number of cache hits to the number +// of lookups, as a percentage. +func (c *Cache) HitRatio() float64 { + if c.numLookups == 0 { + return 0 + } + return float64(c.numHits) / float64(c.numLookups) * 100 +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/dictionary.go b/server/vulnerabilities/nvd/tools/cvefeed/dictionary.go new file mode 100644 index 0000000000..3ddef4322a --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/dictionary.go @@ -0,0 +1,105 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "errors" + "fmt" + "os" + "strings" + "sync" +) + +// Dictionary is a slice of entries +type Dictionary map[string]Vuln + +// Override amends entries in Dictionary with configurations from Dictionary d2; +// CVE will be matched if it matches the original config of d and does not match the config of d2. +func (d *Dictionary) Override(d2 Dictionary) { + if d == nil { + return + } + if *d == nil { + *d = make(Dictionary) + } + for k, cve := range d2 { + if _, ok := (*d)[k]; ok { + (*d)[k] = OverrideVuln((*d)[k], cve) + } + } +} + +// LoadJSONDictionary parses dictionary from multiple NVD vulnerability feed JSON files +func LoadJSONDictionary(paths ...string) (Dictionary, error) { + return LoadFeed(loadJSONFile, paths...) +} + +// LoadFeed calls loadFunc for each file in paths and returns the combined outputs in a Dictionary. +func LoadFeed(loadFunc func(string) ([]Vuln, error), paths ...string) (Dictionary, error) { + dict := make(Dictionary) + var wg sync.WaitGroup + done := make(chan struct{}) + errDone := make(chan struct{}) + dictChan := make(chan []Vuln, 1) + errChan := make(chan error, 1) + for _, path := range paths { + wg.Add(1) + go func(path string) { + defer wg.Done() + feed, err := loadFunc(path) + if err != nil { + errChan <- fmt.Errorf("dictionary: failed to load feed %q: %v", path, err) + return + } + dictChan <- feed + }(path) + } + go func() { + for d := range dictChan { + for _, cve := range d { + if cveid := cve.ID(); cveid != "" { + dict[cveid] = cve + } + } + } + close(done) + }() + var errs []string + go func() { + for e := range errChan { + errs = append(errs, e.Error()) + } + close(errDone) + }() + wg.Wait() + close(dictChan) + close(errChan) + <-done + <-errDone + if len(errs) > 0 { + return dict, errors.New(strings.Join(errs, "\n")) + } + return dict, nil +} + +// loadJSONFile parses dictionary from NVD vulnerability feed JSON file +func loadJSONFile(path string) ([]Vuln, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("dictionary: failed to load feed %q: %v", path, err) + } + defer f.Close() + return ParseJSON(f) +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/eviction_test.go b/server/vulnerabilities/nvd/tools/cvefeed/eviction_test.go new file mode 100644 index 0000000000..595cc54645 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/eviction_test.go @@ -0,0 +1,123 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "bytes" + "sync" + "testing" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +func TestCacheEviction(t *testing.T) { + items, err := LoadFeed(func(_ string) ([]Vuln, error) { + return ParseJSON(bytes.NewBufferString(testJSONdict)) + }, "") + if err != nil { + t.Fatalf("failed to parse the dictionary: %v", err) + } + cache := NewCache(items).SetMaxSize(2 * 1024) + matchingItem := &wfn.Attributes{Part: "a", Vendor: "microsoft", Product: "ie", Version: "5\\.4"} + + // first, run concurrently and enjoy different sizes of cache logged on each run + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(variant int) { + defer wg.Done() + inventory := []*wfn.Attributes{ + matchingItem, + } + for i := 0; i < variant; i++ { + inventory = append(inventory, &wfn.Attributes{Vendor: "huh", Product: "brah"}) + } + matches := cache.Get(inventory) + if len(matches) != 1 { + t.Errorf("variant %d: cache.Get() returned wrong amount of matches (%d, 1 was expected)", variant, len(matches)) + return + } + if len(matches[0].CPEs) != 1 { + t.Errorf("variant %d: cache.Get() returned wrong a match with wrong number of CPEs (%d, 1 was expected)", variant, len(matches[0].CPEs)) + } + if *matches[0].CPEs[0] != *matchingItem { + t.Errorf("variant %d: cache.Get() returned wrong match:\n%+v\n%+v was expected", variant, *matches[0].CPEs[0], *matchingItem) + } + }(i) + } + wg.Wait() + if cache.size > cache.MaxSize { + t.Errorf("concurrent run: cache size exceeds maximum: %d bytes out of %d bytes", cache.size, cache.MaxSize) + } + t.Logf("concurrent run: cache size %d/%d; %d records cached", cache.size, cache.MaxSize, len(cache.data)) + + // now let's get serious and get some deterministic resutls + for i := 0; i < 50; i++ { + variant := i + inventory := []*wfn.Attributes{ + matchingItem, + } + for i := 0; i < variant; i++ { + inventory = append(inventory, &wfn.Attributes{Vendor: "huh", Product: "brah"}) + } + matches := cache.Get(inventory) + if len(matches) != 1 { + t.Fatalf("variant %d: cache.Get() returned wrong amount of matches (%d, 1 was expected)", variant, len(matches)) + } + if len(matches[0].CPEs) != 1 { + t.Errorf("variant %d: cache.Get() returned wrong a match with wrong number of CPEs (%d, 1 was expected)", variant, len(matches[0].CPEs)) + } + if *matches[0].CPEs[0] != *matchingItem { + t.Errorf("variant %d: cache.Get() returned wrong match:\n%+v\n%+v was expected", variant, *matches[0].CPEs[0], *matchingItem) + } + } + if cache.size > cache.MaxSize { + t.Errorf("sequential run #1: cache size exceeds maximum: %d bytes out of %d bytes", cache.size, cache.MaxSize) + } + // the latest cached items are almost 1K long, so there should be only 1 left in the cache + if len(cache.data) > 1 { + t.Errorf("sequential run #1: more than 1 record cached (%d)", len(cache.data)) + } + t.Logf("sequential run #1: cache size %d/%d; %d records cached", cache.size, cache.MaxSize, len(cache.data)) + + // and now let's go the other way around and make cache evict the bigger records first + for i := 39; i >= 0; i-- { + variant := i + inventory := []*wfn.Attributes{ + matchingItem, + } + for i := 0; i < variant; i++ { + inventory = append(inventory, &wfn.Attributes{Vendor: "huh", Product: "brah"}) + } + matches := cache.Get(inventory) + if len(matches) != 1 { + t.Errorf("variant %d: cache.Get() returned wrong amount of matches (%d, 1 was expected)", variant, len(matches)) + } + if len(matches[0].CPEs) != 1 { + t.Errorf("variant %d: cache.Get() returned wrong a match with wrong number of CPEs (%d, 1 was expected)", variant, len(matches[0].CPEs)) + } + if *matches[0].CPEs[0] != *matchingItem { + t.Errorf("variant %d: cache.Get() returned wrong match:\n%+v\n%+v was expected", variant, *matches[0].CPEs[0], *matchingItem) + } + } + if cache.size > cache.MaxSize { + t.Errorf("sequential run #2: cache size exceeds maximum: %d bytes out of %d bytes", cache.size, cache.MaxSize) + } + // Since we touch the smaller records first, we should have more of these cached + if len(cache.data) < 5 { + t.Errorf("sequential run #2: more than 1 record cached (%d)", len(cache.data)) + } + t.Logf("sequential run #2: cache size %d/%d; %d records cached", cache.size, cache.MaxSize, len(cache.data)) +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue.go b/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue.go new file mode 100644 index 0000000000..50267da109 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue.go @@ -0,0 +1,84 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "container/heap" + "time" +) + +type evictionData struct { + key string // which key in Cache.data refers to it + index int // the index of item on the heap + access time.Time // last access time +} + +// evictionQueue is a priority queue for LRU cache +type evictionQueue struct { + q evictionHeap +} + +// pop pops next key to evict +func (eq *evictionQueue) pop() string { + if eq.q.Len() > 0 { + return heap.Pop(&eq.q).(*evictionData).key + } + return "" +} + +// push pushes a key onto heap, returns the index item ended up at. +func (eq *evictionQueue) push(key string) int { + index := eq.q.Len() + ed := &evictionData{ + key: key, + index: index, + access: time.Now(), + } + heap.Push(&eq.q, ed) + return ed.index +} + +// touch updates the access time of the item at index, returns the new index of that item. +func (eq *evictionQueue) touch(index int) int { + ed := eq.q[index] + ed.access = time.Now() + heap.Fix(&eq.q, index) + return ed.index +} + +// evictionHeap is a slice of evictionData that implements heap.Interface +type evictionHeap []*evictionData + +func (eh evictionHeap) Len() int { return len(eh) } + +func (eh evictionHeap) Less(i, j int) bool { return eh[i].access.Before(eh[j].access) } + +func (eh evictionHeap) Swap(i, j int) { + eh[i], eh[j] = eh[j], eh[i] + eh[i].index, eh[j].index = i, j +} + +func (eh *evictionHeap) Push(x interface{}) { + ed := x.(*evictionData) + ed.index = len(*eh) + *eh = append(*eh, ed) +} + +func (eh *evictionHeap) Pop() interface{} { + old := *eh + ed := old[len(old)-1] + *eh = old[:len(old)-1] + return ed +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue_test.go b/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue_test.go new file mode 100644 index 0000000000..2ace2f299f --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/evictionqueue_test.go @@ -0,0 +1,84 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "testing" + "time" +) + +func TestEvictionQueue(t *testing.T) { + var q evictionQueue + cases := []string{"hello", "world", "quux", "baz", "foo"} + for i, c := range cases { + idx := q.push(c) + if idx != i { + t.Errorf("push() returned wrong index %d (%d was expected)", idx, i) + } + time.Sleep(1 * time.Millisecond) + } + + // first, it should appear in order + for i := range cases { + if cases[i] != q.q[i].key { + t.Errorf("unexpected queue order (before touch-ing):\nexpected %v\ngot %v", cases, listKeys(q.q)) + break + } + } + + // touch it in reverse order + for i := len(cases) - 1; i >= 0; i-- { + q.touch(i) + time.Sleep(1 * time.Millisecond) + } + + // now baz and quux should be after foo and hello and world should be the last ones + // but the exact order is non-deterministic + for i, item := range q.q { + switch i { + case 0: + if item.key != "foo" { + t.Errorf("unexpected queue order (after touch-ing): %q at position %d", item.key, i) + } + case 1, 2: + if item.key != "baz" && item.key != "quux" { + t.Errorf("unexpected queue order (after touch-ing): %q at position %d", item.key, i) + } + case 3, 4: + if item.key != "hello" && item.key != "world" { + t.Errorf("unexpected queue order (after touch-ing): %q at position %d", item.key, i) + } + default: + t.Fatal("unreacheable code reached o_O") + } + } + + // but when pop-ing the values from heap, it should come in order reverse to the one we started with + for i := len(cases) - 1; i >= 0; i-- { + item := q.pop() + if item != cases[i] { + t.Errorf("unexpected queue order (while pop-ing):\nexpected %v\ngot %v", cases, listKeys(q.q)) + break + } + } +} + +func listKeys(in []*evictionData) []string { + out := make([]string, len(in)) + for i, item := range in { + out[i] = item.key + } + return out +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/feed.go b/server/vulnerabilities/nvd/tools/cvefeed/feed.go new file mode 100644 index 0000000000..a4d7c23739 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/feed.go @@ -0,0 +1,85 @@ +// Package cvefeed defines types and methods necessary to parse NVD vulnerability +// feed and match an inventory of CPE names against it. +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package cvefeed provides an API to NVD CVE feeds parsing and matching. +package cvefeed + +import ( + "bufio" + "compress/bzip2" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/ioutil" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" +) + +// ParseJSON parses JSON dictionary from NVD vulnerability feed +func ParseJSON(in io.Reader) ([]Vuln, error) { + feed, err := getFeed(in) + if err != nil { + return nil, fmt.Errorf("cvefeed.ParseJSON: %v", err) + } + + vulns := make([]Vuln, 0, len(feed.CVEItems)) + for _, cve := range feed.CVEItems { + if cve != nil && cve.Configurations != nil { + vulns = append(vulns, nvd.ToVuln(cve)) + } + } + return vulns, nil +} + +func getFeed(in io.Reader) (*schema.NVDCVEFeedJSON10, error) { + reader, err := setupReader(in) + if err != nil { + return nil, fmt.Errorf("can't setup reader: %v", err) + } + defer reader.Close() + + var feed schema.NVDCVEFeedJSON10 + if err := json.NewDecoder(reader).Decode(&feed); err != nil { + return nil, err + } + return &feed, nil +} + +func setupReader(in io.Reader) (src io.ReadCloser, err error) { + r := bufio.NewReader(in) + header, err := r.Peek(2) + if err != nil { + return nil, err + } + // assume plain text first + src = ioutil.NopCloser(r) + // replace with gzip.Reader if gzip'ed + if header[0] == 0x1f && header[1] == 0x8b { // file is gzip'ed + zr, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + src = zr + } else if header[0] == 'B' && header[1] == 'Z' { + // or with bzip2.Reader if bzip2'ed + src = ioutil.NopCloser(bzip2.NewReader(r)) + } + // TODO: maybe support .zip + return src, nil +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/matching_json_test.go b/server/vulnerabilities/nvd/tools/cvefeed/matching_json_test.go new file mode 100644 index 0000000000..baa73cad43 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/matching_json_test.go @@ -0,0 +1,300 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "bytes" + "fmt" + "testing" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +func TestBadJSONfeed(t *testing.T) { + items, err := ParseJSON(bytes.NewBufferString(testJSONdictBroken)) + if err != nil { + t.Fatalf("failed to parse the dictionary: %v", err) + } + if len(items) > 0 { + t.Fatalf("expected the broken feed to be ignored, got %d items", len(items)) + } +} + +func TestMatchJSON(t *testing.T) { + cases := []struct { + Rule int + Inventory []*wfn.Attributes + Matches []*wfn.Attributes + }{ + { + Rule: 0, + Inventory: []*wfn.Attributes{}, + }, + { + Rule: 0, + Inventory: []*wfn.Attributes{{}}, + Matches: []*wfn.Attributes{{}}, + }, + { + Inventory: []*wfn.Attributes{ + {Part: "o", Vendor: "linux", Product: "linux_kernel", Version: "2\\.6\\.1"}, + {Part: "a", Vendor: "djvulibre_project", Product: "djvulibre", Version: "3\\.5\\.11"}, + }, + }, + { + Rule: 0, + Inventory: []*wfn.Attributes{ + {Part: "o", Vendor: "microsoft", Product: "windows_xp", Update: "sp3"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "6\\.0"}, + {Part: "a", Vendor: "facebook", Product: "styx", Version: "0\\.1"}, + }, + Matches: []*wfn.Attributes{ + {Part: "o", Vendor: "microsoft", Product: "windows_xp", Update: "sp3"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "6\\.0"}, + }, + }, + { + Rule: 1, + Inventory: []*wfn.Attributes{{}}, + Matches: []*wfn.Attributes{{}}, + }, + { + Rule: 1, + Inventory: []*wfn.Attributes{ + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "3\\.9"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "4\\.0"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "5\\.4"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "6\\.0"}, + }, + Matches: []*wfn.Attributes{ + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "4\\.0"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "5\\.4"}, + }, + }, + { + Rule: 2, + Inventory: []*wfn.Attributes{{}}, + Matches: []*wfn.Attributes{{}}, + }, + { + Rule: 2, + Inventory: []*wfn.Attributes{ + {Part: "a", Vendor: "mozilla", Product: "firefox", Version: "64\\.0"}, + }, + }, + } + items, err := ParseJSON(bytes.NewBufferString(testJSONdict)) + if err != nil { + t.Fatalf("failed to parse the dictionary: %v", err) + } + for i, c := range cases { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + mm := items[c.Rule].Match(c.Inventory, false) + if len(mm) != len(c.Matches) { + t.Fatalf("expected %d matches, got %d matches", len(mm), len(c.Matches)) + } + if len(mm) > 0 && !matchesAll(mm, c.Matches) { + t.Fatalf("wrong match: expected %v, got %v", c.Matches, mm) + } + }) + } +} + +func TestMatchJSONrequireVersion(t *testing.T) { + inventory := []*wfn.Attributes{ + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "6\\.0"}, + } + items, err := ParseJSON(bytes.NewBufferString(testJSONdict)) + if err != nil { + t.Fatalf("failed to parse the dictionary: %v", err) + } + if mm := items[1].Match(inventory, true); len(mm) != 0 { + t.Fatal("platform was expected to be ignored because of absence of version, but matched") + } +} + +func TestMatchJSONsmartVersionMatching(t *testing.T) { + inventory := []*wfn.Attributes{ + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "52\\.0"}, + } + items, err := ParseJSON(bytes.NewBufferString(testJSONdict)) + if err != nil { + t.Fatalf("failed to parse the dictionary: %v", err) + } + if mm := items[1].Match(inventory, true); len(mm) != 0 { + t.Errorf("version %q unexpectedly matched", inventory[0].Version) + } +} + +func BenchmarkMatchJSON(b *testing.B) { + inventory := []*wfn.Attributes{ + {Part: "o", Vendor: "microsoft", Product: "windows_xp", Update: "sp3"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "6\\.0"}, + {Part: "a", Vendor: "facebook", Product: "styx", Version: "0\\.1"}, + } + items, err := ParseJSON(bytes.NewBufferString(testJSONdict)) + if err != nil { + b.Fatalf("failed to parse the dictionary: %v", err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if mm := items[0].Match(inventory, false); len(mm) == 0 { + b.Fatal("expected Match to match, it did not") + } + } +} + +var testJSONdictBroken = `{ + "CVE_data_format":"", + "CVE_data_type":"", + "CVE_data_version":"", + "CVE_Items":[ + {}, + {"cve":null}, + { + "cve": { + "data_type" : "CVE", + "data_format" : "MITRE", + "data_version" : "4.0", + "CVE_data_meta" : { + "ID" : "TESTVE-2018-0001", + "ASSIGNER" : "cve@mitre.org" + } + }, + "configurations": null + } + ] +} +` + +var testJSONdict = `{ +"CVE_data_type" : "CVE", +"CVE_data_format" : "MITRE", +"CVE_data_version" : "4.0", +"CVE_data_numberOfCVEs" : "7083", +"CVE_data_timestamp" : "2018-07-31T07:00Z", +"CVE_Items" : [ + { + "cve" : { + "data_type" : "CVE", + "data_format" : "MITRE", + "data_version" : "4.0", + "CVE_data_meta" : { + "ID" : "TESTVE-2018-0001", + "ASSIGNER" : "cve@mitre.org" + } + }, + "configurations" : { + "CVE_data_version" : "4.0", + "nodes" : [ + { + "operator" : "AND", + "children" : [ + { + "operator" : "OR", + "cpe_match" : [ { + "vulnerable" : true, + "cpe22Uri" : "cpe:/a:microsoft:ie:6.%01", + "cpe23Uri" : "cpe:2.3:a:microsoft:ie:6.*:*:*:*:*:*:*:*" + } ] + }, + { + "operator" : "OR", + "cpe_match" : [ { + "vulnerable" : true, + "cpe22Uri" : "cpe:/o:microsoft:windows_xp::sp%02", + "cpe23Uri" : "cpe:2.3:o:microsoft:windows_xp:*:sp?:*:*:*:*:*:*" + } ] + } + ] + } + ] + } + }, + { + "cve" : { + "data_type" : "CVE", + "data_format" : "MITRE", + "data_version" : "4.0", + "CVE_data_meta" : { + "ID" : "TESTVE-2018-0002", + "ASSIGNER" : "cve@mitre.org" + } + }, + "configurations" : { + "CVE_data_version" : "4.0", + "nodes" : [ + { + "operator" : "AND", + "children" : [ + { + "operator" : "OR", + "cpe_match" : [ { + "vulnerable" : true, + "cpe22Uri" : "cpe:/a:microsoft:ie", + "cpe23Uri" : "cpe:2.3:a:microsoft:ie:*:*:*:*:*:*:*:*", + "versionStartIncluding" : "4.0", + "versionEndExcluding" : "6.0" + } ] + } + ] + } + ] + } + }, + { + "cve": { + "data_format": "MITRE", + "data_type": "CVE", + "data_version": "4.0", + "CVE_data_meta": { + "ASSIGNER": "cve@mitre.org", + "ID": "CVE-2002-2436" + } + }, + "configurations": { + "CVE_data_version": "4.0", + "nodes": [ + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:a:mozilla:firefox:*:*:*:*:*:*:*:*", + "versionEndIncluding": "3.6.24", + "vulnerable": true + } + ], + "operator": "OR" + } + ] + } + } +] }` + +func matchesAll(src, tgt []*wfn.Attributes) bool { + if len(src) != len(tgt) { + return false + } + for i, j := 0, 0; i < len(src); i, j = i+1, 0 { + for ; j < len(tgt); j++ { + if *src[i] == *tgt[j] { + break + } + } + if j == len(tgt) { // reached the end, no match + return false + } + } + return true +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/matching_overrides_test.go b/server/vulnerabilities/nvd/tools/cvefeed/matching_overrides_test.go new file mode 100644 index 0000000000..1dc542bd23 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/matching_overrides_test.go @@ -0,0 +1,111 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "bytes" + "fmt" + "testing" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +func TestMatchOverrides(t *testing.T) { + cases := [][]*wfn.Attributes{ + {}, + { + {Part: "o", Vendor: "linux", Product: "linux_kernel", Version: "2\\.6\\.1"}, + {Part: "a", Vendor: "djvulibre_project", Product: "djvulibre", Version: "3\\.5\\.11"}, + }, + { + {Part: "o", Vendor: "microsoft", Product: "windows_xp", Update: "sp3"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "5\\.0", Update: wfn.NA}, + }, + { + {Part: "o", Vendor: "microsoft", Product: "windows_xp", Update: "sp3"}, + {Part: "a", Vendor: "microsoft", Product: "ie", Version: "5\\.0", Update: "patched"}, + }, + } + dict, err := LoadFeed(func(_ string) ([]Vuln, error) { + return ParseJSON(bytes.NewBufferString(testJSONdict)) + }, "") + if err != nil { + t.Fatalf("could not load test JSON feed: %v", err) + } + original, _ := LoadFeed(func(_ string) ([]Vuln, error) { + return ParseJSON(bytes.NewBufferString(testJSONdict)) + }, "") + overrides, err := LoadFeed(func(_ string) ([]Vuln, error) { + return ParseJSON(bytes.NewBufferString(testJSONoverride)) + }, "") + if err != nil { + t.Fatalf("could not load test overrides: %v", err) + } + dict.Override(overrides) + + for n, c := range cases { + c := c + t.Run(fmt.Sprintf("%d", n), func(t *testing.T) { + var matchOriginal, matchOverride, matchDict bool + if m := dict["TESTVE-2018-0002"].Match(c, false); len(m) > 0 { + matchDict = true + } + if m := original["TESTVE-2018-0002"].Match(c, false); len(m) > 0 { + matchOriginal = true + } + if m := overrides["TESTVE-2018-0002"].Match(c, false); len(m) > 0 { + matchOverride = true + } + if matchOriginal && matchDict && matchOverride { + t.Fatal("case was not overriden") + } else if matchDict && !matchOriginal { + t.Fatal("unexpected match") + } + }) + } +} + +var testJSONoverride = `{ +"CVE_data_type" : "CVE", +"CVE_data_format" : "MITRE", +"CVE_data_version" : "4.0", +"CVE_data_numberOfCVEs" : "7083", +"CVE_data_timestamp" : "2018-07-31T07:00Z", +"CVE_Items" : [ + { + "cve" : { + "data_type" : "CVE", + "data_format" : "MITRE", + "data_version" : "4.0", + "CVE_data_meta" : { + "ID" : "TESTVE-2018-0002", + "ASSIGNER" : "cve@mitre.org" + } + }, + "configurations" : { + "CVE_data_version" : "4.0", + "nodes" : [ + { + "operator" : "OR", + "cpe_match" : [ { + "vulnerable" : true, + "cpe23Uri" : "cpe:2.3:a:microsoft:ie:*:patched:*:*:*:*:*:*" + } ] + } + ] + } + } +] +}` diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cpe.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cpe.go new file mode 100644 index 0000000000..636a78a93a --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cpe.go @@ -0,0 +1,141 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "fmt" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +// cpeMatch is a wrapper around the actual NVDCVEFeedJSON10DefCPEMatch +type cpeMatch struct { + *wfn.Attributes + vulnerable bool + versionEndExcluding string + versionEndIncluding string + versionStartExcluding string + versionStartIncluding string + hasVersionRanges bool +} + +// Matcher returns an object which knows how to match attributes +func cpeMatcher(ID string, nvdMatch *schema.NVDCVEFeedJSON10DefCPEMatch) (wfn.Matcher, error) { + parse := func(uri string) (*wfn.Attributes, error) { + if uri == "" { + return nil, fmt.Errorf("%s: can't parse empty uri", ID) + } + return wfn.Parse(uri) + } + + // parse + match := cpeMatch{vulnerable: nvdMatch.Vulnerable} + var err error + if match.Attributes, err = parse(nvdMatch.Cpe23Uri); err != nil { + if match.Attributes, err = parse(nvdMatch.Cpe22Uri); err != nil { + return nil, fmt.Errorf("%s: unable to parse both cpe2.2 and cpe2.3", ID) + } + } + + match.versionEndExcluding = nvdMatch.VersionEndExcluding + match.versionEndIncluding = nvdMatch.VersionEndIncluding + match.versionStartExcluding = nvdMatch.VersionStartExcluding + match.versionStartIncluding = nvdMatch.VersionStartIncluding + + if match.versionStartIncluding != "" || match.versionStartExcluding != "" || + match.versionEndIncluding != "" || match.versionEndExcluding != "" { + match.hasVersionRanges = true + } + + return &match, nil +} + +// Match is part of the Matcher interface +func (cm *cpeMatch) Match(attrs []*wfn.Attributes, requireVersion bool) (matches []*wfn.Attributes) { + for _, attr := range attrs { + if cm.match(attr, requireVersion) { + matches = append(matches, attr) + } + } + return matches +} + +// Match implements wfn.Matcher interface +func (cm *cpeMatch) match(attr *wfn.Attributes, requireVersion bool) bool { + if cm == nil || cm.Attributes == nil { + return false + } + + if requireVersion { + // if we require version, then we need either version ranges or version not to be * + if !cm.hasVersionRanges && cm.Attributes.Version == wfn.Any { + return false + } + } + + // here we have a version: either actual one or ranges + + // check whether everything except for version matches + if !cm.Attributes.MatchWithoutVersion(attr) { + return false + } + + if cm.Attributes.Version == wfn.Any { + if !cm.hasVersionRanges { + // if version is any and doesn't have version ranges, then it matches any + return !requireVersion + } // otherwise we try to match it at the end of the function + } else if cm.Attributes.MatchOnlyVersion(attr) { + return true // version matched + } + + // if it got to here, it means: + // - matched attr without version + // - didn't match version, or require version was set and version was * + + if attr.Version == wfn.Any { + return true + } + + if !cm.hasVersionRanges { + return false + } + + // if hasVersionRanges and attr version is NA, then return false + if attr.Version == wfn.NA { + return false + } + + // match version to ranges + ver := wfn.StripSlashes(attr.Version) + + matches := true + + if cm.versionStartIncluding != "" { + matches = matches && smartVerCmp(ver, cm.versionStartIncluding) >= 0 + } + if cm.versionStartExcluding != "" { + matches = matches && smartVerCmp(ver, cm.versionStartExcluding) > 0 + } + if cm.versionEndIncluding != "" { + matches = matches && smartVerCmp(ver, cm.versionEndIncluding) <= 0 + } + if cm.versionEndExcluding != "" { + matches = matches && smartVerCmp(ver, cm.versionEndExcluding) < 0 + } + + return matches +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cve.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cve.go new file mode 100644 index 0000000000..f6b789f837 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_cve.go @@ -0,0 +1,176 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "regexp" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +var cveRegex = regexp.MustCompile("CVE-[0-9]{4}-[0-9]{4,}") + +func ToVuln(cve *schema.NVDCVEFeedJSON10DefCVEItem) *Vuln { + vuln := &Vuln{ + cveItem: cve, + } + + var ms []wfn.Matcher + for _, node := range cve.Configurations.Nodes { + if node != nil { + if m, err := nodeMatcher(vuln.ID(), node); err == nil { + ms = append(ms, m) + } + } + } + vuln.Matcher = wfn.MatchAny(ms...) + + return vuln +} + +// Vuln implements the cvefeed.Vuln interface +type Vuln struct { + cveItem *schema.NVDCVEFeedJSON10DefCVEItem + wfn.Matcher +} + +// Schema returns the underlying schema of the Vuln +func (v *Vuln) Schema() *schema.NVDCVEFeedJSON10DefCVEItem { + return v.cveItem +} + +// ID is a part of the cvefeed.Vuln Interface +func (v *Vuln) ID() string { + if v == nil || v.cveItem == nil || v.cveItem.CVE == nil || v.cveItem.CVE.CVEDataMeta == nil { + return "" + } + return v.cveItem.CVE.CVEDataMeta.ID +} + +// CVEs is a part of the cvefeed.Vuln Interface +func (v *Vuln) CVEs() []string { + if v == nil || v.cveItem == nil || v.cveItem.CVE == nil { + return nil + } + + var cves []string + + addMatch := func(s string) bool { + if cve := cveRegex.FindString(s); cve != "" { + cves = append(cves, cve) + return true + } + return false + } + + // check if ID contains CVE + addMatch(v.ID()) + + // add references + if refs := v.cveItem.CVE.References; refs != nil { + for _, refd := range refs.ReferenceData { + if refd != nil { + addMatch(refd.Name) + } + } + } + + return unique(cves) +} + +// CWEs is a part of the cvefeed.Vuln Interface +func (v *Vuln) CWEs() []string { + if v == nil || v.cveItem == nil || v.cveItem.CVE == nil || v.cveItem.CVE.Problemtype == nil { + return nil + } + + var cwes []string + + for _, ptd := range v.cveItem.CVE.Problemtype.ProblemtypeData { + if ptd != nil { + for _, desc := range ptd.Description { + if desc != nil { + if desc.Lang == "en" { + cwes = append(cwes, desc.Value) + } + } + } + } + } + + return unique(cwes) +} + +// CVSSv2BaseScore is a part of the cvefeed.Vuln Interface +func (v *Vuln) CVSSv2BaseScore() float64 { + if c := v.cvssv2(); c != nil { + return c.BaseScore + } + return 0.0 +} + +// CVSSv2Vector is a part of the cvefeed.Vuln Interface +func (v *Vuln) CVSSv2Vector() string { + if c := v.cvssv2(); c != nil { + return c.VectorString + } + return "" +} + +// CVSSv3BaseScore is a part of the cvefeed.Vuln Interface +func (v *Vuln) CVSSv3BaseScore() float64 { + if c := v.cvssv3(); c != nil { + return c.BaseScore + } + return 0.0 +} + +// CVSSv3Vector is a part of the cvefeed.Vuln Interface +func (v *Vuln) CVSSv3Vector() string { + if c := v.cvssv3(); c != nil { + return c.VectorString + } + return "" +} + +// unique returns unique strings from input +func unique(ss []string) []string { + var us []string + set := make(map[string]bool) + for _, s := range ss { + if !set[s] { + us = append(us, s) + } + set[s] = true + } + return us +} + +// just a helper to return the cvssv2 data +func (v *Vuln) cvssv2() *schema.CVSSV20 { + if v == nil || v.cveItem == nil || v.cveItem.Impact == nil || v.cveItem.Impact.BaseMetricV2 == nil { + return nil + } + return v.cveItem.Impact.BaseMetricV2.CVSSV2 +} + +// just a helper to return the cvssv3 data +func (v *Vuln) cvssv3() *schema.CVSSV30 { + if v == nil || v.cveItem == nil || v.cveItem.Impact == nil || v.cveItem.Impact.BaseMetricV3 == nil { + return nil + } + return v.cveItem.Impact.BaseMetricV3.CVSSV3 +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_node.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_node.go new file mode 100644 index 0000000000..e1a1b988bf --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/match_node.go @@ -0,0 +1,69 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "fmt" + "strings" + + "github.com/facebookincubator/flog" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +// Matcher returns an object which knows how to match attributes +func nodeMatcher(ID string, node *schema.NVDCVEFeedJSON10DefNode) (wfn.Matcher, error) { + if node == nil { + return nil, fmt.Errorf("%s: node is nil", ID) + } + + var ms []wfn.Matcher + for _, match := range node.CPEMatch { + if match != nil { + if m, err := cpeMatcher(ID, match); err == nil { + ms = append(ms, m) + } + } + } + for _, child := range node.Children { + if child != nil { + if m, err := nodeMatcher(ID, child); err == nil { + ms = append(ms, m) + } + } + } + + if len(ms) == 0 { + return nil, fmt.Errorf("%s: empty configuration for node", ID) + } + + var m wfn.Matcher + + switch strings.ToUpper(node.Operator) { + default: + flog.Warningf("%s: unknown operator, defaulting to OR: got %q", ID, node.Operator) + fallthrough + case "OR": + m = wfn.MatchAny(ms...) + case "AND": + m = wfn.MatchAll(ms...) + } + + if node.Negate { + m = wfn.DontMatch(m) + } + + return m, nil +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema/schema.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema/schema.go new file mode 100644 index 0000000000..ba17bffaba --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema/schema.go @@ -0,0 +1,255 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schema + +// TimeLayout is the layout of NVD CVE timestamps. +const TimeLayout = "2006-01-02T15:04Z" + +// NVDCVEFeedJSON10DefCPEName was auto-generated. +// CPE name. +type NVDCVEFeedJSON10DefCPEName struct { + Cpe22Uri string `json:"cpe22Uri,omitempty"` + Cpe23Uri string `json:"cpe23Uri"` +} + +// NVDCVEFeedJSON10DefCPEMatch was auto-generated. +// CPE match string or range. +type NVDCVEFeedJSON10DefCPEMatch struct { + CPEName []*NVDCVEFeedJSON10DefCPEName `json:"cpe_name,omitempty"` + Cpe22Uri string `json:"cpe22Uri,omitempty"` + Cpe23Uri string `json:"cpe23Uri"` + VersionEndExcluding string `json:"versionEndExcluding,omitempty"` + VersionEndIncluding string `json:"versionEndIncluding,omitempty"` + VersionStartExcluding string `json:"versionStartExcluding,omitempty"` + VersionStartIncluding string `json:"versionStartIncluding,omitempty"` + Vulnerable bool `json:"vulnerable"` +} + +// NVDCVEFeedJSON10DefNode was auto-generated. +// Defines a node or sub-node in an NVD applicability statement. +type NVDCVEFeedJSON10DefNode struct { + CPEMatch []*NVDCVEFeedJSON10DefCPEMatch `json:"cpe_match,omitempty"` + Children []*NVDCVEFeedJSON10DefNode `json:"children,omitempty"` + Negate bool `json:"negate,omitempty"` + Operator string `json:"operator,omitempty"` +} + +// NVDCVEFeedJSON10DefConfigurations was auto-generated. +// Defines the set of product configurations for a NVD applicability statement. +type NVDCVEFeedJSON10DefConfigurations struct { + CVEDataVersion string `json:"CVE_data_version"` + Nodes []*NVDCVEFeedJSON10DefNode `json:"nodes,omitempty"` +} + +// CVEJSON40CVEDataMeta was auto-generated. +type CVEJSON40CVEDataMeta struct { + ASSIGNER string `json:"ASSIGNER"` + ID string `json:"ID"` + STATE string `json:"STATE,omitempty"` +} + +// CVEJSON40ProductVersionVersionData was auto-generated. +type CVEJSON40ProductVersionVersionData struct { + VersionAffected string `json:"version_affected,omitempty"` + VersionValue string `json:"version_value"` +} + +// CVEJSON40ProductVersion was auto-generated. +type CVEJSON40ProductVersion struct { + VersionData []*CVEJSON40ProductVersionVersionData `json:"version_data"` +} + +// CVEJSON40Product was auto-generated. +type CVEJSON40Product struct { + ProductName string `json:"product_name"` + Version *CVEJSON40ProductVersion `json:"version"` +} + +// CVEJSON40AffectsVendorVendorDataProduct was auto-generated. +type CVEJSON40AffectsVendorVendorDataProduct struct { + ProductData []*CVEJSON40Product `json:"product_data"` +} + +// CVEJSON40AffectsVendorVendorData was auto-generated. +type CVEJSON40AffectsVendorVendorData struct { + Product *CVEJSON40AffectsVendorVendorDataProduct `json:"product"` + VendorName string `json:"vendor_name"` +} + +// CVEJSON40AffectsVendor was auto-generated. +type CVEJSON40AffectsVendor struct { + VendorData []*CVEJSON40AffectsVendorVendorData `json:"vendor_data"` +} + +// CVEJSON40Affects was auto-generated. +type CVEJSON40Affects struct { + Vendor *CVEJSON40AffectsVendor `json:"vendor"` +} + +// CVEJSON40LangString was auto-generated. +type CVEJSON40LangString struct { + Lang string `json:"lang"` + Value string `json:"value"` +} + +// CVEJSON40Description was auto-generated. +type CVEJSON40Description struct { + DescriptionData []*CVEJSON40LangString `json:"description_data"` +} + +// CVEJSON40ProblemtypeProblemtypeData was auto-generated. +type CVEJSON40ProblemtypeProblemtypeData struct { + Description []*CVEJSON40LangString `json:"description"` +} + +// CVEJSON40Problemtype was auto-generated. +type CVEJSON40Problemtype struct { + ProblemtypeData []*CVEJSON40ProblemtypeProblemtypeData `json:"problemtype_data"` +} + +// CVEJSON40Reference was auto-generated. +type CVEJSON40Reference struct { + Name string `json:"name,omitempty"` + Refsource string `json:"refsource,omitempty"` + Tags []string `json:"tags,omitempty"` + URL string `json:"url"` +} + +// CVEJSON40References was auto-generated. +type CVEJSON40References struct { + ReferenceData []*CVEJSON40Reference `json:"reference_data"` +} + +// CVEJSON40 was auto-generated. +// Source: https://csrc.nist.gov/schema/nvd/feed/1.0/CVE_JSON_4.0_min.schema +type CVEJSON40 struct { + Affects *CVEJSON40Affects `json:"affects"` + CVEDataMeta *CVEJSON40CVEDataMeta `json:"CVE_data_meta"` + DataFormat string `json:"data_format"` + DataType string `json:"data_type"` + DataVersion string `json:"data_version"` + Description *CVEJSON40Description `json:"description"` + Problemtype *CVEJSON40Problemtype `json:"problemtype"` + References *CVEJSON40References `json:"references"` +} + +// CVSSV20 was auto-generated. +// Source: https://csrc.nist.gov/schema/nvd/feed/1.0/cvss-v2.0.json +type CVSSV20 struct { + AccessComplexity string `json:"accessComplexity,omitempty"` + AccessVector string `json:"accessVector,omitempty"` + Authentication string `json:"authentication,omitempty"` + AvailabilityImpact string `json:"availabilityImpact,omitempty"` + AvailabilityRequirement string `json:"availabilityRequirement,omitempty"` + BaseScore float64 `json:"baseScore"` + CollateralDamagePotential string `json:"collateralDamagePotential,omitempty"` + ConfidentialityImpact string `json:"confidentialityImpact,omitempty"` + ConfidentialityRequirement string `json:"confidentialityRequirement,omitempty"` + EnvironmentalScore float64 `json:"environmentalScore,omitempty"` + Exploitability string `json:"exploitability,omitempty"` + IntegrityImpact string `json:"integrityImpact,omitempty"` + IntegrityRequirement string `json:"integrityRequirement,omitempty"` + RemediationLevel string `json:"remediationLevel,omitempty"` + ReportConfidence string `json:"reportConfidence,omitempty"` + TargetDistribution string `json:"targetDistribution,omitempty"` + TemporalScore float64 `json:"temporalScore,omitempty"` + VectorString string `json:"vectorString"` + Version string `json:"version"` +} + +// NVDCVEFeedJSON10DefImpactBaseMetricV2 was auto-generated. +// CVSS V2.0 score. +type NVDCVEFeedJSON10DefImpactBaseMetricV2 struct { + AcInsufInfo bool `json:"acInsufInfo,omitempty"` + CVSSV2 *CVSSV20 `json:"cvssV2,omitempty"` + ExploitabilityScore float64 `json:"exploitabilityScore,omitempty"` + ImpactScore float64 `json:"impactScore,omitempty"` + ObtainAllPrivilege bool `json:"obtainAllPrivilege,omitempty"` + ObtainOtherPrivilege bool `json:"obtainOtherPrivilege,omitempty"` + ObtainUserPrivilege bool `json:"obtainUserPrivilege,omitempty"` + Severity string `json:"severity,omitempty"` + UserInteractionRequired bool `json:"userInteractionRequired,omitempty"` +} + +// CVSSV30 was auto-generated. +// Source: https://csrc.nist.gov/schema/nvd/feed/1.0/cvss-v3.0.json +type CVSSV30 struct { + AttackComplexity string `json:"attackComplexity,omitempty"` + AttackVector string `json:"attackVector,omitempty"` + AvailabilityImpact string `json:"availabilityImpact,omitempty"` + AvailabilityRequirement string `json:"availabilityRequirement,omitempty"` + BaseScore float64 `json:"baseScore"` + BaseSeverity string `json:"baseSeverity"` + ConfidentialityImpact string `json:"confidentialityImpact,omitempty"` + ConfidentialityRequirement string `json:"confidentialityRequirement,omitempty"` + EnvironmentalScore float64 `json:"environmentalScore,omitempty"` + EnvironmentalSeverity string `json:"environmentalSeverity,omitempty"` + ExploitCodeMaturity string `json:"exploitCodeMaturity,omitempty"` + IntegrityImpact string `json:"integrityImpact,omitempty"` + IntegrityRequirement string `json:"integrityRequirement,omitempty"` + ModifiedAttackComplexity string `json:"modifiedAttackComplexity,omitempty"` + ModifiedAttackVector string `json:"modifiedAttackVector,omitempty"` + ModifiedAvailabilityImpact string `json:"modifiedAvailabilityImpact,omitempty"` + ModifiedConfidentialityImpact string `json:"modifiedConfidentialityImpact,omitempty"` + ModifiedIntegrityImpact string `json:"modifiedIntegrityImpact,omitempty"` + ModifiedPrivilegesRequired string `json:"modifiedPrivilegesRequired,omitempty"` + ModifiedScope string `json:"modifiedScope,omitempty"` + ModifiedUserInteraction string `json:"modifiedUserInteraction,omitempty"` + PrivilegesRequired string `json:"privilegesRequired,omitempty"` + RemediationLevel string `json:"remediationLevel,omitempty"` + ReportConfidence string `json:"reportConfidence,omitempty"` + Scope string `json:"scope,omitempty"` + TemporalScore float64 `json:"temporalScore,omitempty"` + TemporalSeverity string `json:"temporalSeverity,omitempty"` + UserInteraction string `json:"userInteraction,omitempty"` + VectorString string `json:"vectorString"` + Version string `json:"version"` +} + +// NVDCVEFeedJSON10DefImpactBaseMetricV3 was auto-generated. +// CVSS V3.0 score. +type NVDCVEFeedJSON10DefImpactBaseMetricV3 struct { + CVSSV3 *CVSSV30 `json:"cvssV3,omitempty"` + ExploitabilityScore float64 `json:"exploitabilityScore,omitempty"` + ImpactScore float64 `json:"impactScore,omitempty"` +} + +// NVDCVEFeedJSON10DefImpact was auto-generated. +// Impact scores for a vulnerability as found on NVD. +type NVDCVEFeedJSON10DefImpact struct { + BaseMetricV2 *NVDCVEFeedJSON10DefImpactBaseMetricV2 `json:"baseMetricV2,omitempty"` + BaseMetricV3 *NVDCVEFeedJSON10DefImpactBaseMetricV3 `json:"baseMetricV3,omitempty"` +} + +// NVDCVEFeedJSON10DefCVEItem was auto-generated. +// Defines a vulnerability in the NVD data feed. +type NVDCVEFeedJSON10DefCVEItem struct { + CVE *CVEJSON40 `json:"cve"` + Configurations *NVDCVEFeedJSON10DefConfigurations `json:"configurations,omitempty"` + Impact *NVDCVEFeedJSON10DefImpact `json:"impact,omitempty"` + LastModifiedDate string `json:"lastModifiedDate,omitempty"` + PublishedDate string `json:"publishedDate,omitempty"` +} + +// NVDCVEFeedJSON10 was auto-generated. +// Source: https://csrc.nist.gov/schema/nvd/feed/1.0/nvd_cve_feed_json_1.0.schema +type NVDCVEFeedJSON10 struct { + CVEDataFormat string `json:"CVE_data_format"` + CVEDataNumberOfCVEs string `json:"CVE_data_numberOfCVEs,omitempty"` + CVEDataTimestamp string `json:"CVE_data_timestamp,omitempty"` + CVEDataType string `json:"CVE_data_type"` + CVEDataVersion string `json:"CVE_data_version"` + CVEItems []*NVDCVEFeedJSON10DefCVEItem `json:"CVE_Items"` +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp.go new file mode 100644 index 0000000000..c6a0927a1c --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp.go @@ -0,0 +1,98 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "strings" +) + +// smartVerCmp compares stringified versions of software. +// It tries to do the right thing for any type of versioning, +// assuming v1 and v2 have the same version convension. +// It will return meaningful result for "95SE" vs "98SP1" or for "16.3.2" vs. "3.7.0", +// but not for "2000" vs "11.7". +// Returns -1 if v1 < v2, 1 if v1 > v2 and 0 if v1 == v2. +func smartVerCmp(v1, v2 string) int { + s1, s2 := v1, v2 + for len(s1) > 0 && len(s2) > 0 { + num1, cmpTo1, skip1 := parseVerParts(s1) + num2, cmpTo2, skip2 := parseVerParts(s2) + + ns1 := s1[:cmpTo1] + ns2 := s2[:cmpTo2] + diff := num1 - num2 + switch { + case diff > 0: // ns1 has longer numeric part + ns2 = lpad(ns2, diff) + case diff < 0: // ns2 has longer numeric part + ns1 = lpad(ns1, -diff) + } + + if cmp := strings.Compare(ns1, ns2); cmp != 0 { + return cmp + } + + s1 = s1[skip1:] + s2 = s2[skip2:] + } + // everything is equal so far, the longest wins + if len(s1) > len(s2) { + return 1 + } + if len(s2) > len(s1) { + return -1 + } + return 0 +} + +// parseVerParts returns the length of consecutive run of digits in the beginning of the string, +// the last non-separator chararcted (which should be compared), and index at which the version part (major, minor etc.) ends, +// i.e. the position of the dot or end of the line. +// E.g. parseVerParts("11b.4.16-New_Year_Edition") will return (2, 3, 4) +func parseVerParts(v string) (int, int, int) { + var num int + for num = 0; num < len(v); num++ { + if v[num] < '0' || v[num] > '9' { + break + } + } + if num == len(v) { + return num, num, num + } + // Any punctuation separates the parts. + skip := strings.IndexFunc(v, func(b rune) bool { + // !"#$%&'()*+,-./ are dec 33 to 47, :;<=>?@ are dec 58 to 64, [\]^_` are dec 91 to 96 and {|}~ are dec 123 to 126. + // So, punctuation is in dec 33-126 range except 48-57, 65-90 and 97-122 gaps. + // This inverse logic allows for early short-circuting for most of the chars and shaves ~20ns in benchmarks. + return b >= '!' && b <= '~' && + !(b > '/' && b < ':' || + b > '@' && b < '[' || + b > '`' && b < '{') + }) + if skip == -1 { + return num, len(v), len(v) + } + return num, skip, skip + 1 +} + +// lpad pads s with n '0's +func lpad(s string, n int) string { + var sb strings.Builder + for i := 0; i < n; i++ { + sb.WriteByte('0') + } + sb.WriteString(s) + return sb.String() +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp_test.go b/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp_test.go new file mode 100644 index 0000000000..8909a71f06 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/nvd/smartvercmp_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "fmt" + "testing" +) + +func TestSmartVerCmp(t *testing.T) { + cases := []struct { + v1, v2 string + ret int + }{ + {"5", "8", -1}, + {"15", "3", 1}, + {"4a", "4c", -1}, + {"1.0", "1.0", 0}, + {"1.0.1", "1.0", 1}, + {"1.0.14", "1.0.4", 1}, + {"95SE", "98SP1", -1}, + {"16.0.0", "3.2.7", 1}, + {"10.23", "10.21", 1}, + {"64.0", "3.6.24", 1}, + {"5-1.15.2", "5-1.16", -1}, + {"5-appl_1.16.1", "5-1.0.1", -1}, // this is wrong, but seems to be impossible to account for + {"5-1.16", "5_1.0.6", 1}, + {"5-6", "5-16", -1}, + {"5-a1", "5a1", -1}, // meh, kind of makes sense + {"5-a1", "5.a1", 0}, + {"1.4", "1.02", 1}, + {"5.0", "08.0", -1}, + {"10.0", "1.0", 1}, + {"2023.02.13", "2023.2.13", 0}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%q vs %q", c.v1, c.v2), func(t *testing.T) { + if ret := smartVerCmp(c.v1, c.v2); ret != c.ret { + t.Fatalf("expected %d, got %d", c.ret, ret) + } + }) + } +} + +func BenchmarkSmartVerCmp(b *testing.B) { + cases := []struct { + v1, v2 string + }{ + {"1.0", "1.0"}, + {"1.0.1", "1.0"}, + {"1.0.14", "1.0.4"}, + {"95SE", "98SP1"}, + {"16.0.0", "3.2.7"}, + {"10.23", "10.21"}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, c := range cases { + smartVerCmp(c.v1, c.v2) + } + } +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/stats.go b/server/vulnerabilities/nvd/tools/cvefeed/stats.go new file mode 100644 index 0000000000..56c0920261 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/stats.go @@ -0,0 +1,154 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "fmt" + "sort" + "strings" + "sync" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/cvefeed/nvd/schema" +) + +var cpeParts = map[string]string{ + "a": "application", + "h": "hardware", + "o": "operating system", +} + +type stack struct { + items []string + rwLock sync.RWMutex +} + +func (s *stack) push(item string) { + s.rwLock.Lock() + defer s.rwLock.Unlock() + if s.items == nil { + s.items = []string{} + } + s.items = append(s.items, item) +} + +func (s *stack) pop() (string, bool) { + if s.isEmpty() { + return "", false + } + s.rwLock.Lock() + defer s.rwLock.Unlock() + item := s.items[len(s.items)-1] + s.items = s.items[0 : len(s.items)-1] + return item, true +} + +func (s *stack) isEmpty() bool { + s.rwLock.Lock() + defer s.rwLock.Unlock() + return len(s.items) == 0 +} + +// Stats contains the stats information of a NVD JSON feed +type Stats struct { + totalCVEs int64 + totalRules int64 + totalRulesWithAND int64 + operatorANDs map[string]int64 +} + +// NewStats creates a new Stats object +func NewStats() *Stats { + s := Stats{} + s.Reset() + return &s +} + +// Reset clears out a Stats object +func (s *Stats) Reset() { + s.totalCVEs = 0 + s.totalRules = 0 + s.totalRulesWithAND = 0 + s.operatorANDs = make(map[string]int64) +} + +// ReportOperatorAND prints the stats of operator AND +func (s *Stats) ReportOperatorAND() { + if s.totalRulesWithAND <= 0 { + fmt.Println("No rules found with AND operator.") + return + } + keys := make([]string, 0, len(s.operatorANDs)) + for key := range s.operatorANDs { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return s.operatorANDs[keys[i]] > s.operatorANDs[keys[j]] + }) + fmt.Printf("Total rules with AND operator: %0.2f%%\n", percentage(s.totalRulesWithAND, s.totalRules)) + for _, key := range keys { + fmt.Printf("%05.2f%%: %s\n", percentage(s.operatorANDs[key], s.totalRulesWithAND), key) + } +} + +// Gather feeds a Stats object by gathering stats from a NVD JSON feed dictionary +func (s *Stats) Gather(dict Dictionary) { + for key := range dict { + s.totalCVEs++ + schema := dict[key].(*nvd.Vuln).Schema() + for _, node := range schema.Configurations.Nodes { + s.totalRules++ + rule := flattenRule(node, &stack{}) + if strings.Contains(rule, "AND") { + s.totalRulesWithAND++ + s.operatorANDs[rule]++ + } + } + } +} + +func flattenRule(node *schema.NVDCVEFeedJSON10DefNode, operators *stack) string { + cpePart := "" + operators.push(node.Operator) + switch { + case len(node.Children) > 0: + outputs := []string{} + for _, c := range node.Children { + outputs = append(outputs, flattenRule(c, operators)) + } + operator, _ := operators.pop() + return fmt.Sprintf("(%s)", strings.Join(outputs, fmt.Sprintf(" %s ", operator))) + case len(node.CPEMatch) > 0: + for _, cpeMatch := range node.CPEMatch { + cpeItems := strings.Split(cpeMatch.Cpe23Uri, ":") + if len(cpeItems) > 2 { + part := cpeItems[2] + if _, ok := cpeParts[part]; ok && !strings.Contains(cpePart, part) { + cpePart += part + } + } + } + operator, _ := operators.pop() + if len(cpePart) > 1 { + return fmt.Sprintf("(%s)", strings.Join(strings.Split(cpePart, ""), fmt.Sprintf(" %s ", operator))) + } + } + return cpePart +} + +func percentage(partial, total int64) (delta float64) { + delta = (float64(partial) / float64(total)) * 100 + return +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/stats_test.go b/server/vulnerabilities/nvd/tools/cvefeed/stats_test.go new file mode 100644 index 0000000000..be98bcca94 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/stats_test.go @@ -0,0 +1,200 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +var testFeed = ` +{ + "CVE_Items": [ + { + "cve": { + "affects": null, + "CVE_data_meta": { + "ASSIGNER": "cve@mitre.org", + "ID": "CVE-2020-1111" + }, + "data_format": "MITRE", + "data_type": "CVE", + "data_version": "4.0", + "description": { + "description_data": [ + { + "lang": "en", + "value": "" + } + ] + }, + "problemtype": { + "problemtype_data": [ + { + "description": [ + { + "lang": "en", + "value": "CWE-20" + } + ] + } + ] + }, + "references": { + "reference_data": [ + { + "name": "", + "refsource": "", + "tags": [ + "Vendor Advisory" + ], + "url": "" + }, + { + "name": "test", + "refsource": "MISC", + "url": "" + } + ] + } + }, + "configurations": { + "CVE_data_version": "4.0", + "nodes": [ + { + "children": [ + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:a:test:test:-:*:*:*:*:*:*:*", + "vulnerable": true + } + ], + "operator": "OR" + }, + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:h:test:test:-:*:*:*:*:*:*:*", + "vulnerable": false + } + ], + "operator": "OR" + } + ], + "operator": "AND" + }, + { + "children": [ + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:a:test:test:-:*:*:*:*:*:*:*", + "vulnerable": true + } + ], + "operator": "OR" + }, + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:h:test:test:-:*:*:*:*:*:*:*", + "vulnerable": false + }, + { + "cpe23Uri": "cpe:2.3:h:test:test:-:*:*:*:*:*:*:*", + "vulnerable": false + } + ], + "operator": "OR" + } + ], + "operator": "AND" + }, + { + "children": [ + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:o:test:test:-:*:*:*:*:*:*:*", + "vulnerable": true + } + ], + "operator": "OR" + }, + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:a:test:test:-:*:*:*:*:*:*:*", + "vulnerable": false + }, + { + "cpe23Uri": "cpe:2.3:h:test:test:-:*:*:*:*:*:*:*", + "vulnerable": false + } + ], + "operator": "OR" + } + ], + "operator": "AND" + }, + { + "cpe_match": [ + { + "cpe23Uri": "cpe:2.3:a:test:test:-:*:*:*:*:*:*:*", + "vulnerable": true + } + ], + "operator": "OR" + } + ] + } + } + ] +} +` + +func TestStats(t *testing.T) { + file, err := ioutil.TempFile("/tmp", "test_nvd.json") + assert.Nil(t, err, "Unexpected error occurred when creating a temp file") + defer func() { + assert.Nil(t, file.Close(), "Unexpected error occurred when closing a temp file") + assert.Nil(t, os.Remove(file.Name()), "Unexpected error occurred when removing a temp file") + }() + _, err = file.WriteString(testFeed) + assert.Nil(t, err, "Unexpected error occurred when writing to a temp file") + feedDict, err := LoadJSONDictionary(file.Name()) + assert.Nil(t, err, "Unexpected error occurred when loading a test NVD JSON feed file") + + stats := NewStats() + stats.Gather(feedDict) + + orgStdOut := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + stats.ReportOperatorAND() + w.Close() + output, _ := ioutil.ReadAll(r) + os.Stdout = orgStdOut + + expectedOutput := `Total rules with AND operator: 75.00% +66.67%: (a AND h) +33.33%: (o AND (a OR h)) +` + assert.Equal(t, expectedOutput, string(output)) +} diff --git a/server/vulnerabilities/nvd/tools/cvefeed/vuln.go b/server/vulnerabilities/nvd/tools/cvefeed/vuln.go new file mode 100644 index 0000000000..1316b5edeb --- /dev/null +++ b/server/vulnerabilities/nvd/tools/cvefeed/vuln.go @@ -0,0 +1,80 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cvefeed + +import ( + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/wfn" +) + +// Vuln is a vulnerability interface +type Vuln interface { + // vulnerability should also be able to match attributes + wfn.Matcher + // ID returns the vulnerability ID + ID() string + // CVEs returns all CVEs it includes/references + CVEs() []string + // CWEs returns all CWEs for this vulnerability + CWEs() []string + // CVSSv2BaseScore returns CVSS v2 base score + CVSSv2BaseScore() float64 + // CVSSv2BaseScore returns CVSS v2 vector + CVSSv2Vector() string + // CVSSv2BaseScore returns CVSS v3 base score + CVSSv3BaseScore() float64 + // CVSSv2BaseScore returns CVSS v3 vector + CVSSv3Vector() string +} + +// MergeVuln combines two Vulns: +// resulted Vuln inherits all mutually exclusive methods (e.g. ID()) from Vuln x; +// functions returning CVEs and CWEs return distinct(union(x,y)) +// the returned vuln matches attributes if x matches AND y doesn't +func OverrideVuln(v, override Vuln) Vuln { + return &overriden{ + Vuln: v, + matcher: &andMatcher{v, wfn.DontMatch(override)}, + } +} + +type overriden struct { + Vuln + matcher wfn.Matcher +} + +// Match is a part of the wfn.Matcher interface +func (v *overriden) Match(attrs []*wfn.Attributes, requireVersion bool) []*wfn.Attributes { + return v.matcher.Match(attrs, requireVersion) +} + +// Attrs is a part of the wfn.Matcher interface +func (v *overriden) Config() []*wfn.Attributes { + return v.matcher.Config() +} + +// matches are the ones matched by both +type andMatcher struct { + m1, m2 wfn.Matcher +} + +// Match is a part of the wfn.Matcher interface +func (m *andMatcher) Match(attrs []*wfn.Attributes, requireVersion bool) []*wfn.Attributes { + return m.m2.Match(m.m1.Match(attrs, requireVersion), requireVersion) +} + +// Attrs is a part of the wfn.Matcher interface +func (m *andMatcher) Config() []*wfn.Attributes { + return append(m.m1.Config(), m.m2.Config()...) +} diff --git a/server/vulnerabilities/nvd/tools/nvdtools.spec b/server/vulnerabilities/nvd/tools/nvdtools.spec new file mode 100644 index 0000000000..2d08ba12ff --- /dev/null +++ b/server/vulnerabilities/nvd/tools/nvdtools.spec @@ -0,0 +1,28 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + +Name: nvdtools +Summary: A collection of tools for working with National Vulnerability Database feeds. +Version: %{_version} +Release: 1 +License: Apache License 2.0 +URL: https://github.com/facebookincubator/nvdtools +Source0: %{name}-%{version}.tar.gz + +%description +A set of tools to work with the feeds (vulnerabilities, CPE dictionary etc.) distributed by National Vulnerability Database (NVD). + +%prep +%setup -q + +%build +make GOFLAGS="-ldflags=-linkmode=external" + +%install +make install DESTDIR=$RPM_BUILD_ROOT + +%files +%license LICENSE +%{_bindir}/* +/usr/share/doc/nvdtools + +%changelog diff --git a/server/vulnerabilities/nvd/tools/providers/lib/client/client.go b/server/vulnerabilities/nvd/tools/providers/lib/client/client.go new file mode 100644 index 0000000000..f70487ab3c --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/lib/client/client.go @@ -0,0 +1,66 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "context" + "fmt" + "net/http" +) + +// Client is an interface used for making http requests +type Client interface { + Do(req *http.Request) (*http.Response, error) + Get(url string) (*http.Response, error) +} + +// Default returns the default http client to use +func Default() Client { + return http.DefaultClient +} + +// Get will create a GET request with given headers and call Do on the client +func Get(ctx context.Context, c Client, url string, header http.Header) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("cannot create http get request: %v", err) + } + req.Header = header + + id := traceRequestStart(req) + if debug.failRequestNum == id { + return nil, &Err{ + Code: 503, + Status: "Service Unavailable", + Body: "Request cancelled by debug feature", + } + } + resp, err := c.Do(req) + traceRequestEnd(id, resp) + + return resp, err +} + +// Err encapsulates stuff from the http.Response +type Err struct { + Code int + Status string + Body string +} + +// Error is a part of the error interface +func (e *Err) Error() string { + return fmt.Sprintf("http error %s:\n %q", e.Status, e.Body) +} diff --git a/server/vulnerabilities/nvd/tools/providers/lib/client/debug.go b/server/vulnerabilities/nvd/tools/providers/lib/client/debug.go new file mode 100644 index 0000000000..c00fa78a0b --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/lib/client/debug.go @@ -0,0 +1,119 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "fmt" + "net/http" + "net/http/httputil" + "os" + "strconv" + "sync/atomic" + + "github.com/facebookincubator/flog" +) + +var debug struct { + // Print HTTP requests and responses to stderr. + traceRequests bool + // Print the bodies of HTTP requests and responses to stderr. + traceRequestBodies bool + // When tools issue concurrent GET requests, the normal behaviour is to + // cancel pending requests as soon as one request fails. This option + // restores the old behaviour or executing the remaning requests anyway. + continueDownloading bool + // When set to a number n, the n th HTTP request will fail. + failRequestNum uint64 + + requestNum uint64 +} + +func getBool(varName string) bool { + v, _ := strconv.ParseBool(os.Getenv(varName)) + return v +} + +func getUint(varName string, defaultValue uint64) uint64 { + v, err := strconv.ParseUint(os.Getenv(varName), 10, 64) + if err != nil { + return defaultValue + } + return v +} + +func init() { + debug.traceRequests = getBool("NVD_TRACE_REQUESTS") + debug.traceRequestBodies = getBool("NVD_TRACE_REQUEST_BODIES") + debug.continueDownloading = getBool("NVD_CONTINUE_DOWNLOADING") + debug.failRequestNum = getUint("NVD_FAIL_REQUEST", 0) +} + +func obfuscateHeaders(req *http.Request) *http.Request { + authHeaders := []string{ + "Authorization", + // fireeye + "X-Auth", + "X-Auth-Hash", + // idefense + "Auth-Token", + } + + headers := req.Header.Clone() + for _, header := range authHeaders { + if headers.Get(header) == "" { + continue + } + headers.Set(header, "") + } + + // A shallow copy is enough for this usage. + newReq := *req + newReq.Header = headers + return &newReq +} + +func traceRequestStart(req *http.Request) uint64 { + id := atomic.AddUint64(&debug.requestNum, 1) + if !debug.traceRequests { + return id + } + data, _ := httputil.DumpRequest(obfuscateHeaders(req), debug.traceRequestBodies) + fmt.Fprintf(os.Stderr, "Req %d: %s", id, string(data)) + return id +} + +func traceRequestEnd(id uint64, resp *http.Response) { + if !debug.traceRequests { + return + } + if resp == nil { + return + } + data, _ := httputil.DumpResponse(resp, debug.traceRequestBodies) + fmt.Fprintf(os.Stderr, "Req %d: %s", id, string(data)) +} + +// StopOrContinue can help controlling the behaviour of concurrent GET requests +// when using an errgroup and encountering an error. Depending on the +// NVD_CONTINUE_DOWNLOADING env variable, this function will return the passed +// error (when we want to stop pending requests) or just log the error (when we +// want the pending requests to continue being processed). +func StopOrContinue(err error) error { + if debug.continueDownloading { + flog.Errorln(err) + return nil + } + return err +} diff --git a/server/vulnerabilities/nvd/tools/providers/lib/rate/rate.go b/server/vulnerabilities/nvd/tools/providers/lib/rate/rate.go new file mode 100644 index 0000000000..41f0551a74 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/lib/rate/rate.go @@ -0,0 +1,51 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rate + +import ( + "time" +) + +// Limiter provides only one function: Allow. it blocks until routine can proceed +type Limiter interface { + Allow() +} + +type token struct{} + +type limiter struct { + tokens chan token +} + +// BurstyLimiter will create a limiter which allows bursts of maximum requestsPerPeriod +// and otherwise allows requests with period/requestsPerPeriod gap in between +func BurstyLimiter(period time.Duration, requestsPerPeriod int) Limiter { + l := &limiter{ + tokens: make(chan token, requestsPerPeriod), + } + + // start filling indefinitely + go func() { + for range time.Tick(period / time.Duration(requestsPerPeriod)) { + l.tokens <- token{} + } + }() + + return l +} + +func (l *limiter) Allow() { + <-l.tokens +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go b/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go new file mode 100644 index 0000000000..def9db3c06 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/cpe.go @@ -0,0 +1,224 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/facebookincubator/flog" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/providers/lib/client" +) + +// CPE defines the CPE data feed for synchronization. +type CPE int + +// Supported CPE feeds. +const ( + cpe23xmlGz CPE = iota // CPE database in XML 2.3 format, gzip compressed. + cpe23xmlZip // CPE database in XML 2.3 format, zip compressed. + cpe22xmlGz // CPE database in XML 2.2 format, gzip compressed. + cpe22xmlZip // CPE database in XML 2.2 format, zip compressed. +) + +// SupportedCPE contains all supported CPE data feeds indexed by name. +var SupportedCPE = map[string]CPE{ + "cpe-2.2.xml.gz": cpe22xmlGz, + "cpe-2.2.xml.zip": cpe22xmlZip, + "cpe-2.3.xml.gz": cpe23xmlGz, + "cpe-2.3.xml.zip": cpe23xmlZip, +} + +// Set implements the flag.Value interface. +func (c *CPE) Set(v string) error { + feed, exists := SupportedCPE[v] + if !exists { + return fmt.Errorf("unsupported CPE feed: %q", v) + } + *c = feed + return nil +} + +// String implements the fmt.Stringer interface. +func (c CPE) String() string { + return "cpe-" + c.version() + ".xml." + c.compression() +} + +// Help returns the CPE flag help. +func (c CPE) Help() string { + opts := make([]string, 0, len(SupportedCPE)) + for k := range SupportedCPE { + opts = append(opts, k) + } + sort.Strings(opts) + return fmt.Sprintf( + "CPE feed to sync (default: %s)\navailable:\n%s", + c, strings.Join(opts, "\n"), + ) +} + +// compression returns the data feed compression: gz or zip. +func (c CPE) compression() string { + switch c { + case cpe22xmlGz, cpe23xmlGz: + return "gz" + case cpe22xmlZip, cpe23xmlZip: + return "zip" + default: + panic("unsupported CPE compression") + } +} + +// version returns the data feed version. +func (c CPE) version() string { + switch c { + case cpe22xmlGz, cpe22xmlZip: + return "2.2" + case cpe23xmlGz, cpe23xmlZip: + return "2.3" + default: + panic("unsupported CPE version") + } +} + +// Sync synchronizes the CPE feed to a local directory. +func (c CPE) Sync(ctx context.Context, src SourceConfig, localdir string) error { + basename := "official-cpe-dictionary_v" + c.version() + cf := cpeFile{ + CPE: c, + EtagFile: basename + ".etag", + DataFile: basename + ".xml." + c.compression(), + } + return cf.Sync(ctx, src, localdir) +} + +type cpeFile struct { + CPE + EtagFile string + DataFile string +} + +func (cf cpeFile) baseURL(src SourceConfig) string { + u := url.URL{ + Scheme: src.Scheme, + Host: src.Host, + Path: src.CPEFeedPath, + } + baseURL := u.String() + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + return baseURL +} + +func (cf cpeFile) Sync(ctx context.Context, src SourceConfig, localdir string) error { + baseURL := cf.baseURL(src) + sourceURL := baseURL + cf.DataFile + needsUpdate, err := cf.needsUpdate(ctx, sourceURL, localdir) + if err != nil { + return err + } + if !needsUpdate { + return nil + } + etag, tempDataFilename, err := cf.download(ctx, sourceURL) + if err != nil { + return err + } + defer os.Remove(tempDataFilename) + + // write etag file + etagFilename := filepath.Join(localdir, cf.EtagFile) + err = ioutil.WriteFile(etagFilename, []byte(etag), 0o644) + if err != nil { + return err + } + + // write data file + dataFilename := filepath.Join(localdir, cf.DataFile) + bakDataFilename := dataFilename + ".bak" + _ = xRename(dataFilename, bakDataFilename) + if err = xRename(tempDataFilename, dataFilename); err != nil { + _ = xRename(bakDataFilename, dataFilename) + return err + } + os.Remove(bakDataFilename) + return nil +} + +func (cf cpeFile) needsUpdate(ctx context.Context, targetURL, localdir string) (bool, error) { + flog.V(1).Infof("checking etag for %q", targetURL) + req, err := httpNewRequestContext(ctx, "HEAD", targetURL) + if err != nil { + return false, err + } + resp, err := client.Default().Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if err = httpResponseNotOK(resp); err != nil { + return false, err + } + remoteEtag := resp.Header.Get("Etag") + if remoteEtag == "" { + return false, fmt.Errorf("server not returning etag header for %q", targetURL) + } + etagBytes, err := ioutil.ReadFile(filepath.Join(localdir, cf.EtagFile)) + if err != nil { + flog.V(1).Infof("etag file %q for not exist in %q, needs sync", cf.EtagFile, localdir) + return true, nil + } + localEtag := string(etagBytes) + if localEtag != remoteEtag { + flog.V(1).Infof("data file %q needs update in %q: hash mismatch %q != %q", cf.DataFile, localdir, localEtag, remoteEtag) + return true, nil + } + return false, nil +} + +// download file from targetURL, returns etag and path to local file. +func (cf cpeFile) download(ctx context.Context, targetURL string) (string, string, error) { + flog.V(1).Infof("downloading data file %q", targetURL) + req, err := http.NewRequest("GET", targetURL, nil) + if err != nil { + return "", "", err + } + resp, err := client.Default().Do(req.WithContext(ctx)) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if err = httpResponseNotOK(resp); err != nil { + return "", "", err + } + dataFile, err := ioutil.TempFile("", "nvdsync-data-") + if err != nil { + return "", "", err + } + _, err = io.Copy(dataFile, resp.Body) + if err != nil { + return "", "", err + } + return resp.Header.Get("Etag"), dataFile.Name(), nil +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/cpe_test.go b/server/vulnerabilities/nvd/tools/providers/nvd/cpe_test.go new file mode 100644 index 0000000000..68de82ef3a --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/cpe_test.go @@ -0,0 +1,61 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "os" + "testing" +) + +func TestCPE(t *testing.T) { + td, err := ioutil.TempDir("", "nvdsync-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(td) + + handler := &cpeTestServer{} + ts, src := httptestNewServer(handler) + defer ts.Close() + + cases := make([]CPE, 0, len(SupportedCPE)) + for _, cve := range SupportedCPE { + cases = append(cases, cve) + } + + for _, cpe := range cases { + label := []string{"CreateSync", "UseExistingSync"} + for i := 0; i < 2; i++ { + info := fmt.Sprintf("%s/%s", label[i], cpe) + t.Run(info, func(t *testing.T) { + err = cpe.Sync(context.Background(), src, td) + if err != nil { + t.Fatal(err) + } + }) + } + } +} + +type cpeTestServer struct{} + +func (ts cpeTestServer) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Etag", "foobar") + fmt.Fprintf(w, "hello, world") +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/cve.go b/server/vulnerabilities/nvd/tools/providers/nvd/cve.go new file mode 100644 index 0000000000..2392f0b1e6 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/cve.go @@ -0,0 +1,586 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" + "time" + + "github.com/facebookincubator/flog" + "github.com/fleetdm/fleet/v4/server/vulnerabilities/nvd/tools/providers/lib/client" +) + +// CVE defines the CVE data feed for synchronization. +type CVE int + +// Supported CVE feeds. +const ( + cve20xmlGz CVE = iota // CVE database in XML 2.0 format, gzip compressed. + cve20xmlZip // CVE database in XML 2.0 format, zip compressed. + cve12xmlGz // CVE database in XML 1.2 format, gzip compressed. + cve12xmlZip // CVE database in XML 1.2 format, zip compressed. + cve10jsonGz // CVE database in JSON 1.0 format, gzip compressed. + cve10jsonZip // CVE database in JSON 1.0 format, zip compressed. + cve11jsonGz // CVE database in JSON 1.1 format, gzip compressed. + cve11jsonZip // CVE database in JSON 1.1 format, zip compressed. +) + +// SupportedCVE contains all supported CVE feeds indexed by name. +var SupportedCVE = map[string]CVE{ + "cve-1.2.xml.gz": cve12xmlGz, + "cve-1.2.xml.zip": cve12xmlZip, + "cve-2.0.xml.gz": cve20xmlGz, + "cve-2.0.xml.zip": cve20xmlZip, + "cve-1.0.json.gz": cve10jsonGz, + "cve-1.0.json.zip": cve10jsonZip, + "cve-1.1.json.gz": cve11jsonGz, + "cve-1.1.json.zip": cve11jsonZip, +} + +// Set implements the flag.Value interface. +func (c *CVE) Set(v string) error { + feed, exists := SupportedCVE[v] + if !exists { + return fmt.Errorf("unsupported CVE feed: %q", v) + } + *c = feed + return nil +} + +// String implements the fmt.Stringer interface. +func (c CVE) String() string { + return "cve-" + c.version() + "." + c.encoding() + "." + c.compression() +} + +// Help returns the CVE flag help. +func (c CVE) Help() string { + opts := make([]string, 0, len(SupportedCVE)) + for k := range SupportedCVE { + opts = append(opts, k) + } + sort.Strings(opts) + return fmt.Sprintf( + "CVE feed to sync (default: %s)\navailable:\n%s", + c, strings.Join(opts, "\n"), + ) +} + +// encoding returns the data feed encoding: xml or json. +func (c CVE) encoding() string { + switch c { + case cve12xmlGz, cve12xmlZip, cve20xmlGz, cve20xmlZip: + return "xml" + case cve10jsonGz, cve10jsonZip, cve11jsonGz, cve11jsonZip: + return "json" + default: + panic("unsupported CVE encoding") + } +} + +// compression returns the data feed compression: gz or zip. +func (c CVE) compression() string { + switch c { + case cve10jsonGz, cve11jsonGz, cve12xmlGz, cve20xmlGz: + return "gz" + case cve10jsonZip, cve11jsonZip, cve12xmlZip, cve20xmlZip: + return "zip" + default: + panic("unsupported CVE compression") + } +} + +// version returns the data feed version. +func (c CVE) version() string { + switch c { + case cve12xmlGz, cve12xmlZip: + return "1.2" + case cve20xmlGz, cve20xmlZip: + return "2.0" + case cve10jsonGz, cve10jsonZip: + return "1.0" + case cve11jsonGz, cve11jsonZip: + return "1.1" + default: + panic("unsupported CVE version") + } +} + +// Sync synchronizes the CVE feed to a local directory. +func (c CVE) Sync(ctx context.Context, src SourceConfig, localdir string) error { + var err error + files := cveFileList(c) + for _, f := range files { + if err = f.Sync(ctx, src, localdir); err != nil { + return err + } + } + return nil +} + +func cveFileList(c CVE) []cveFile { + filefmt := func(version, suffix, encoding, compression string) string { + s := fmt.Sprintf("nvdcve-%s-%s.%s", version, suffix, encoding) + if compression != "" { + s += "." + compression + } + return s + } + + // nvd data feeds start in 2002 + const startingYear = 2002 + currentYear := time.Now().Year() + if currentYear < startingYear { + panic("system date is in the past, cannot continue") + } + + entries := (currentYear - startingYear) + 1 + f := make([]cveFile, entries+2) // +recent +modified + + version := c.version() + encoding := c.encoding() + compression := c.compression() + + for i := 0; i < entries; i++ { + year := startingYear + i + suffix := strconv.Itoa(year) + f[i] = cveFile{ + CVE: c, + MetaFile: filefmt(version, suffix, "meta", ""), + DataFile: filefmt(version, suffix, encoding, compression), + } + } + + // recent + f[entries] = cveFile{ + CVE: c, + MetaFile: filefmt(version, "recent", "meta", ""), + DataFile: filefmt(version, "recent", encoding, compression), + } + + // modified + f[entries+1] = cveFile{ + CVE: c, + MetaFile: filefmt(version, "modified", "meta", ""), + DataFile: filefmt(version, "modified", encoding, compression), + } + + return f +} + +type cveFile struct { + CVE + MetaFile string + DataFile string +} + +func (cf cveFile) baseURL(src SourceConfig) (string, error) { + tmpl, err := template.New("path").Parse(src.CVEFeedPath) + if err != nil { + return "", err + } + b := bytes.Buffer{} + err = tmpl.Execute(&b, struct { + Encoding string + Version string + }{ + Encoding: cf.encoding(), + Version: cf.version(), + }) + if err != nil { + return "", err + } + u := url.URL{ + Scheme: src.Scheme, + Host: src.Host, + Path: b.String(), + } + baseURL := u.String() + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + return baseURL, nil +} + +func (cf cveFile) Sync(ctx context.Context, src SourceConfig, localdir string) error { + baseURL, err := cf.baseURL(src) + if err != nil { + return err + } + remoteMetaURL := baseURL + cf.MetaFile + flog.V(1).Infof("checking meta file %q for updates to %q", cf.MetaFile, cf.DataFile) + remoteMeta, needsUpdate, err := cf.needsUpdate(ctx, remoteMetaURL, localdir) + if err != nil { + return err + } + if !needsUpdate { + return nil + } + remoteFileURL := baseURL + cf.DataFile + tempDataFilename, err := cf.downloadAndVerify(ctx, remoteMeta, remoteFileURL) + if err != nil { + return err + } + defer os.Remove(tempDataFilename) + + // write metadata file + metaFilename := filepath.Join(localdir, cf.MetaFile) + err = remoteMeta.WriteFile(metaFilename) + if err != nil { + return err + } + + // write data file + dataFilename := filepath.Join(localdir, cf.DataFile) + bakDataFilename := dataFilename + ".bak" + _ = xRename(dataFilename, bakDataFilename) + if err = xRename(tempDataFilename, dataFilename); err != nil { + _ = xRename(bakDataFilename, dataFilename) + return err + } + os.Remove(bakDataFilename) + return nil +} + +func (cf cveFile) needsUpdate(ctx context.Context, remoteMetaURL, localdir string) (*metaFile, bool, error) { + flog.V(1).Infof("downloading meta file %q", remoteMetaURL) + remoteMeta, err := newMetaFromURL(ctx, remoteMetaURL) + if err != nil { + return nil, false, err + } + metaFilename := filepath.Join(localdir, cf.MetaFile) + if _, err := os.Stat(metaFilename); os.IsNotExist(err) { + flog.V(1).Infof("meta file %q does not exist in %q, needs sync", cf.MetaFile, localdir) + return &remoteMeta, true, nil + } + localMeta, err := newMetaFromFile(metaFilename) + if err != nil { + return nil, false, err + } + if !localMeta.Equal(remoteMeta) { + flog.V(1).Infof("data file %q needs update in %q: local%+v != remote%+v", cf.DataFile, localdir, localMeta, remoteMeta) + return &remoteMeta, true, nil + } + dataFilename := filepath.Join(localdir, cf.DataFile) + fi, err := os.Stat(dataFilename) + if err != nil { + if os.IsNotExist(err) { + flog.V(1).Infof("data file %q does not exist in %q, needs sync", cf.DataFile, localdir) + return &remoteMeta, true, nil + } + return nil, false, err + } + var sizeOK bool + var hashFunc func(filename string) (string, error) + switch cf.compression() { + case "gz": + sizeOK = fi.Size() == int64(localMeta.GzSize) + hashFunc = gunzipFileAndComputeSHA256 + case "zip": + sizeOK = fi.Size() == int64(localMeta.ZipSize) + hashFunc = unzipFileAndComputeSHA256 + } + if !sizeOK { + flog.V(1).Infof("data file %q needs update in %q: size mismatch", cf.DataFile, localdir) + return &remoteMeta, true, nil + } + hash, err := hashFunc(dataFilename) + if err != nil { + return nil, false, err + } + if hash != localMeta.SHA256 { + flog.V(1).Infof("data file %q needs update in %q: hash mismatch %q != %q", cf.DataFile, localdir, hash, localMeta.SHA256) + return &remoteMeta, true, nil + } + return &remoteMeta, false, nil +} + +// downloadAndVerify downloads a remote file into a temporary local file, and performs checksum using size and hash from m. +// Returns the path to the local file. +func (cf cveFile) downloadAndVerify(ctx context.Context, m *metaFile, remoteFileURL string) (string, error) { + req, err := httpNewRequestContext(ctx, "GET", remoteFileURL) + if err != nil { + return "", err + } + flog.V(1).Infof("downloading data file %q", remoteFileURL) + resp, err := client.Default().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err = httpResponseNotOK(resp); err != nil { + return "", err + } + var wantSize int64 + var hashFunc func(filename string) (string, error) + switch cf.compression() { + case "gz": + wantSize = int64(m.GzSize) + hashFunc = gunzipFileAndComputeSHA256 + case "zip": + wantSize = int64(m.ZipSize) + hashFunc = unzipFileAndComputeSHA256 + } + if resp.ContentLength != wantSize { + return "", fmt.Errorf( + "unexpected size for %q (%s): want %d, have %d", + remoteFileURL, resp.Status, wantSize, resp.ContentLength, + ) + } + dataFile, err := ioutil.TempFile("", "nvdsync-data-") + if err != nil { + return "", err + } + _, err = io.Copy(dataFile, resp.Body) + dataFile.Close() + if err != nil { + return "", err + } + hash, err := hashFunc(dataFile.Name()) + if err != nil { + defer os.Remove(dataFile.Name()) // TODO: delet? + return "", err + } + if hash != m.SHA256 { + defer os.Remove(dataFile.Name()) // TODO: delet? + return "", fmt.Errorf( + "unexpected hash for %q (%s): want %q, have %q", + remoteFileURL, resp.Status, m.SHA256, hash, + ) + } + return dataFile.Name(), nil +} + +// metaFile represents a .meta file from CVE data feeds. +type metaFile struct { + LastModifiedDate time.Time + Size int + ZipSize int + GzSize int + SHA256 string +} + +// Equal compares two meta files. +func (m metaFile) Equal(other metaFile) bool { + switch { + case + !m.LastModifiedDate.Equal(other.LastModifiedDate), + m.Size != other.Size, + m.ZipSize != other.ZipSize, + m.GzSize != other.GzSize, + m.SHA256 != other.SHA256: + return false + } + return true +} + +// WriteTo writes the contents of m to w. +func (m metaFile) WriteTo(w io.Writer) (int64, error) { + lines := []string{ + "lastModifiedDate:%s\r\n", + "size:%d\r\n", + "zipSize:%d\r\n", + "gzSize:%d\r\n", + "sha256:%s\r\n", + } + params := []interface{}{ + m.LastModifiedDate.Format(time.RFC3339), + m.Size, + m.ZipSize, + m.GzSize, + strings.ToUpper(m.SHA256), + } + var total int64 + for i := 0; i < len(lines); i++ { + n, err := fmt.Fprintf(w, lines[i], params[i]) + if err != nil { + return 0, err + } + total += int64(n) + } + return total, nil +} + +// WriteFile writes m to a file. +func (m metaFile) WriteFile(name string) error { + f, err := ioutil.TempFile("", "nvdsync-meta-") + if err != nil { + return err + } + _, err = m.WriteTo(f) + f.Close() + if err != nil { + return err + } + bak := name + ".bak" + _ = xRename(name, bak) + if err = xRename(f.Name(), name); err != nil { + _ = xRename(bak, name) + return err + } + os.Remove(bak) + return err +} + +// newMetaFile loads metadata from r. +func newMetaFile(r io.Reader) (metaFile, error) { + m := metaFile{} + r = io.LimitReader(r, 16*1024) + b, err := ioutil.ReadAll(r) + if err != nil { + return m, err + } + lines := bytes.Split(b, []byte("\r\n")) + for i, line := range lines { + if len(line) == 0 { + break + } + lineno := i + 1 + parts := bytes.SplitN(line, []byte(":"), 2) + if len(parts) != 2 { + return m, fmt.Errorf("line %d: expecting key:value not %q", lineno, string(line)) + } + key := string(parts[0]) + val := string(parts[1]) + switch key { + case "lastModifiedDate": + t, err := time.Parse(time.RFC3339, val) + if err != nil { + return m, fmt.Errorf("line %d: expecting lastModifiedDate={RFC3339} not %q", lineno, string(line)) + } + m.LastModifiedDate = t + case "size": + v, err := strconv.Atoi(val) + if err != nil { + return m, fmt.Errorf("line %d: expecting size={int} not %q", lineno, string(line)) + } + m.Size = v + case "zipSize": + v, err := strconv.Atoi(val) + if err != nil { + return m, fmt.Errorf("line %d: expecting zipSize={int} not %q", lineno, string(line)) + } + m.ZipSize = v + case "gzSize": + v, err := strconv.Atoi(val) + if err != nil { + return m, fmt.Errorf("line %d: expecting gzSize={int} not %q", lineno, string(line)) + } + m.GzSize = v + case "sha256": + m.SHA256 = strings.ToUpper(val) + } + } + return m, nil +} + +// newMetaFromURL loads metadata from a URL pointing to a .meta file. +func newMetaFromURL(ctx context.Context, url string) (metaFile, error) { + m := metaFile{} + req, err := httpNewRequestContext(ctx, "GET", url) + if err != nil { + return m, err + } + resp, err := client.Default().Do(req) + if err != nil { + return m, err + } + defer resp.Body.Close() + if err = httpResponseNotOK(resp); err != nil { + return m, err + } + m, err = newMetaFile(resp.Body) + if err != nil { + return m, fmt.Errorf("malformed data in remote metadata %q: %v", url, err) + } + return m, nil +} + +// newMetaFromFile loads metadata from a local .meta file. +func newMetaFromFile(filename string) (metaFile, error) { + m := metaFile{} + f, err := os.Open(filename) + if err != nil { + return m, err + } + defer f.Close() + m, err = newMetaFile(f) + if err != nil { + return m, fmt.Errorf("malformed data in local metadata %q: %v", filename, err) + } + return m, nil +} + +func computeSHA256(r io.Reader) (string, error) { + hasher := sha256.New() + _, err := io.Copy(hasher, r) + if err != nil { + return "", err + } + hash := hasher.Sum(nil) + return strings.ToUpper(hex.EncodeToString(hash)), nil +} + +func gunzipAndComputeSHA256(r io.Reader) (string, error) { + f, err := gzip.NewReader(r) + if err != nil { + return "", err + } + defer f.Close() + return computeSHA256(f) +} + +func gunzipFileAndComputeSHA256(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + return gunzipAndComputeSHA256(f) +} + +func unzipFileAndComputeSHA256(filename string) (string, error) { + f, err := zip.OpenReader(filename) + if err != nil { + return "", err + } + defer f.Close() + if len(f.File) != 1 { + return "", fmt.Errorf( + "unexpected number of files in zip %q: want 1, have %d", + filename, len(f.File), + ) + } + ff, err := f.File[0].Open() + if err != nil { + return "", err + } + defer ff.Close() + return computeSHA256(ff) +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/cve_test.go b/server/vulnerabilities/nvd/tools/providers/nvd/cve_test.go new file mode 100644 index 0000000000..a102897f32 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/cve_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "testing" +) + +var ( + cveGoldenMetaFile = strings.Join([]string{ + "lastModifiedDate:2018-03-16T23:05:50-04:00", + "size:11", + "zipSize:169", + "gzSize:33", + "sha256:B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9", + }, "\r\n") + + cveGoldenDataFileGz = []byte{ + 0x1f, 0x8b, 0x08, 0x08, 0x42, 0x4d, 0xac, 0x5a, 0x02, 0x03, 0x66, 0x00, + 0xcb, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x01, + 0x00, 0x85, 0x11, 0x4a, 0x0d, 0x0b, 0x00, 0x00, 0x00, + } + + cveGoldenDataFileZip = []byte{ + 0x50, 0x4b, 0x03, 0x04, 0x0a, 0x00, 0x02, 0x00, 0x00, 0x00, 0x6f, 0xb8, + 0x70, 0x4c, 0x85, 0x11, 0x4a, 0x0d, 0x0b, 0x00, 0x00, 0x00, 0x0b, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x1c, 0x00, 0x74, 0x65, 0x73, 0x74, 0x55, 0x54, + 0x09, 0x00, 0x03, 0x42, 0x4d, 0xac, 0x5a, 0xeb, 0x80, 0xac, 0x5a, 0x75, + 0x78, 0x0b, 0x00, 0x01, 0x04, 0xad, 0x62, 0x2a, 0x64, 0x04, 0xba, 0x2d, + 0xd3, 0x6f, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x77, 0x6f, 0x72, 0x6c, + 0x64, 0x50, 0x4b, 0x01, 0x02, 0x1e, 0x03, 0x0a, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x6f, 0xb8, 0x70, 0x4c, 0x85, 0x11, 0x4a, 0x0d, 0x0b, 0x00, 0x00, + 0x00, 0x0b, 0x00, 0x00, 0x00, 0x04, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0xa4, 0x81, 0x00, 0x00, 0x00, 0x00, 0x74, + 0x65, 0x73, 0x74, 0x55, 0x54, 0x05, 0x00, 0x03, 0x42, 0x4d, 0xac, 0x5a, + 0x75, 0x78, 0x0b, 0x00, 0x01, 0x04, 0xad, 0x62, 0x2a, 0x64, 0x04, 0xba, + 0x2d, 0xd3, 0x6f, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x4a, 0x00, 0x00, 0x00, 0x49, 0x00, 0x00, 0x00, 0x00, + 0x00, + } +) + +func TestCVE(t *testing.T) { + cases := make([]CVE, 0, len(SupportedCVE)) + for _, cve := range SupportedCVE { + cases = append(cases, cve) + } + + td, err := ioutil.TempDir("", "nvdsync-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(td) + + handler := &cveTestServer{} + ts, src := httptestNewServer(handler) + defer ts.Close() + + for _, cve := range cases { + // run each test twice, one to create the mirror and another to compare + label := []string{"CreateSync", "UseExistingSync"} + for i := 0; i < 2; i++ { + info := fmt.Sprintf("%s/%s", label[i], cve) + t.Run(info, func(t *testing.T) { + handler.compression = cve.compression() + err = cve.Sync(context.Background(), src, td) + if err != nil { + t.Fatal(err) + } + }) + } + } +} + +type cveTestServer struct { + compression string +} + +func (ts cveTestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".meta") { + _, _ = io.Copy(w, bytes.NewBufferString(cveGoldenMetaFile)) + return + } + switch ts.compression { + case "gz": + _, _ = io.Copy(w, bytes.NewBuffer(cveGoldenDataFileGz)) + case "zip": + _, _ = io.Copy(w, bytes.NewBuffer(cveGoldenDataFileZip)) + } +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/doc.go b/server/vulnerabilities/nvd/tools/providers/nvd/doc.go new file mode 100644 index 0000000000..bd1e185832 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/doc.go @@ -0,0 +1,21 @@ +// Package datafeed provides NVD data feed synchronization for nvdsync. +// +// Designed for https://nvd.nist.gov/vuln/data-feeds. +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package nvd + +// Version of nvdsync/datasync. +const Version = "1.0" diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/e2e_test.go b/server/vulnerabilities/nvd/tools/providers/nvd/e2e_test.go new file mode 100644 index 0000000000..15e96fe556 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/e2e_test.go @@ -0,0 +1,66 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "context" + "flag" + "io/ioutil" + "os" + "testing" + "time" +) + +var ( + e2eEnabled bool + e2eSource = NewSourceConfig() + e2eTimeout = 5 * time.Minute + e2eCVE = cve20xmlGz + e2eCPE = cpe23xmlGz +) + +// test: go test -v -args -v=1 -logtostderr -e2e_enabled +func init() { + flag.BoolVar(&e2eEnabled, "e2e_enabled", e2eEnabled, "enable end-to-end test") + flag.DurationVar(&e2eTimeout, "e2e_timeout", e2eTimeout, "timeout for end-to-end test") + flag.Var(&e2eCVE, "e2e_cve_feed", e2eCVE.Help()) + flag.Var(&e2eCPE, "e2e_cpe_feed", e2eCPE.Help()) + e2eSource.AddFlags(flag.CommandLine) +} + +func TestEndToEnd(t *testing.T) { + if !e2eEnabled { + t.Skip("e2e tests not enabled") + } + + d, err := ioutil.TempDir("", "nvdsync-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(d) + + ds := Sync{ + Feeds: []Syncer{e2eCVE, e2eCPE}, + Source: e2eSource, + LocalDir: d, + } + + ctx, cancel := context.WithTimeout(context.Background(), e2eTimeout) + defer cancel() + + if err = ds.Do(ctx); err != nil { + t.Fatal(err) + } +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/http.go b/server/vulnerabilities/nvd/tools/providers/nvd/http.go new file mode 100644 index 0000000000..2f161103b2 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/http.go @@ -0,0 +1,63 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "regexp" +) + +var userAgent = "nvdsync-" + Version + +// http helpers + +func httpNewRequestContext(ctx context.Context, method, path string) (*http.Request, error) { + req, err := http.NewRequest(method, path, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", UserAgent()) + return req.WithContext(ctx), nil +} + +func httpResponseNotOK(resp *http.Response) error { + if resp.StatusCode == http.StatusOK { + return nil + } + body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 4*1024)) + if err != nil { + return err + } + return fmt.Errorf("unexpected http response from %q (%q): %q", + resp.Request.URL.String(), resp.Status, string(body)) +} + +// SetUserAgent sets the value of User-Agent HTTP header for the client +func SetUserAgent(ua string) error { + if !regexp.MustCompile("^[[:ascii:]]+$").MatchString(ua) { + return fmt.Errorf("non-ascii character in User-Agent header: %q", ua) + } + userAgent = ua + return nil +} + +// UserAgent returns the value of User-Agent HTTP header used by the client +func UserAgent() string { + return userAgent +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/http_test.go b/server/vulnerabilities/nvd/tools/providers/nvd/http_test.go new file mode 100644 index 0000000000..15c8aa5365 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/http_test.go @@ -0,0 +1,61 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// http test helpers + +func httptestNewServer(f http.Handler) (*httptest.Server, SourceConfig) { + ts := httptest.NewServer(f) + + tsurl, _ := url.Parse(ts.URL) + src := SourceConfig{ + Scheme: tsurl.Scheme, + Host: tsurl.Host, + CVEFeedPath: "/", + CPEFeedPath: "/", + } + return ts, src +} + +func TestResponseNotOK(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "hello world") + })) + defer ts.Close() + + resp, err := http.Get(ts.URL) + if err != nil { + t.Fatal(err) + } + + err = httpResponseNotOK(resp) + if err == nil { + t.Fatal("unexpected response OK") + } + + if !strings.Contains(err.Error(), "hello world") { + t.Fatalf("unexpected response: %q", err) + } +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/src.go b/server/vulnerabilities/nvd/tools/providers/nvd/src.go new file mode 100644 index 0000000000..4f2932fcf5 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/src.go @@ -0,0 +1,60 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "flag" + "os" + "reflect" +) + +// SourceConfig is the configuration of the NVD data feed source. +type SourceConfig struct { + Scheme string `envconfig:"NVDSYNC_SCHEME" default:"https"` + Host string `envconfig:"NVDSYNC_HOST" default:"nvd.nist.gov"` + CVEFeedPath string `envconfig:"NVDSYNC_CVE_FEED_PATH" default:"/feeds/{{.Encoding}}/cve/{{.Version}}/"` + CPEFeedPath string `envconfig:"NVDSYNC_CPE_FEED_PATH" default:"/feeds/xml/cpe/dictionary/"` +} + +// NewSourceConfig creates and initializes a new SourceConfig with values from envconfig. +func NewSourceConfig() *SourceConfig { + sc := &SourceConfig{} + + valueFromStructTag := func(f reflect.StructField) string { + k := f.Tag.Get("envconfig") + if v := os.Getenv(k); v != "" { + return v + } + return f.Tag.Get("default") + } + + t := reflect.TypeOf(sc).Elem() + p := reflect.ValueOf(sc).Elem() + for i := 0; i < p.NumField(); i++ { + field := t.Field(i) + value := reflect.ValueOf(valueFromStructTag(field)) + p.Field(i).Set(value) + } + + return sc +} + +// AddFlags adds SourceConfig flags to the given FlagSet. +func (src *SourceConfig) AddFlags(_ *flag.FlagSet) { + flag.StringVar(&src.Scheme, "src_scheme", src.Scheme, "source scheme\nenv: NVDSYNC_SCHEME") + flag.StringVar(&src.Host, "src_host", src.Host, "source host\nenv: NVDSYNC_HOST") + flag.StringVar(&src.CVEFeedPath, "src_cve_feed_path", src.CVEFeedPath, "source path for CVE feeds\nenv: NVDSYNC_CVE_FEED_PATH") + flag.StringVar(&src.CPEFeedPath, "src_cpe_feed_path", src.CPEFeedPath, "source path for CPE feeds\nenv: NVDSYNC_CPE_FEED_PATH") +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/sync.go b/server/vulnerabilities/nvd/tools/providers/nvd/sync.go new file mode 100644 index 0000000000..e251a5686f --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/sync.go @@ -0,0 +1,72 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "context" + "fmt" + "os" + "strings" +) + +// Syncer is an abstract interface for data feed synchronizers. +type Syncer interface { + Sync(ctx context.Context, src SourceConfig, localdir string) error +} + +// SyncError accumulates errors occured during Sync.Do() call. +type SyncError []string + +// Error implements error interface. +func (se SyncError) Error() string { + if len(se) == 0 { + return "" + } + sfx := "" + if len(se) > 1 { + sfx = "s" + } + return fmt.Sprintf("%d synchronisation error%s:\n\t%s", len(se), sfx, strings.Join(se, "\n\t")) +} + +// Sync provides full synchronization between remote and local data feeds. +type Sync struct { + Feeds []Syncer + Source *SourceConfig + LocalDir string +} + +// Do executes the synchronization. +func (s Sync) Do(ctx context.Context) error { + err := os.MkdirAll(s.LocalDir, 0755) + if err != nil { + return err + } + src := s.Source + if src == nil { + src = NewSourceConfig() + } + vsrc := *src + var errors SyncError + for _, feed := range s.Feeds { + if err = feed.Sync(ctx, vsrc, s.LocalDir); err != nil { + errors = append(errors, err.Error()) + } + } + if len(errors) == 0 { + return nil + } + return errors +} diff --git a/server/vulnerabilities/nvd/tools/providers/nvd/xrename.go b/server/vulnerabilities/nvd/tools/providers/nvd/xrename.go new file mode 100644 index 0000000000..36aa674c4c --- /dev/null +++ b/server/vulnerabilities/nvd/tools/providers/nvd/xrename.go @@ -0,0 +1,51 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nvd + +import ( + "fmt" + "io" + "os" +) + +// xRename tries to rename oldpath to newpath, if it gets LinkError (most often +// because of the files located on a different device) it copies and removes +// it instead +func xRename(oldpath, newpath string) error { + err := os.Rename(oldpath, newpath) + if _, ok := err.(*os.LinkError); ok { + var oldfile, newfile *os.File + if oldfile, err = os.Open(oldpath); err != nil { + return err + } + defer oldfile.Close() + var finfo os.FileInfo + if finfo, err = oldfile.Stat(); err != nil { + return err + } + if !finfo.Mode().IsRegular() { + return fmt.Errorf("failed to rename %q to %q: source file is not a regular file", oldpath, newpath) + } + if newfile, err = os.OpenFile(newpath, os.O_WRONLY|os.O_CREATE, finfo.Mode().Perm()); err != nil { + return err + } + defer newfile.Close() + if _, err = io.Copy(newfile, oldfile); err != nil { + return err + } + err = os.Remove(oldpath) + } + return err +} diff --git a/server/vulnerabilities/nvd/tools/wfn/doc.go b/server/vulnerabilities/nvd/tools/wfn/doc.go new file mode 100644 index 0000000000..7fbea8d7be --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/doc.go @@ -0,0 +1,18 @@ +// Package wfn provides a representation, bindings and matching of the Well-Formed CPE names as per +// https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7695.pdf and +// https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7696.pdf +// +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package wfn diff --git a/server/vulnerabilities/nvd/tools/wfn/fsb.go b/server/vulnerabilities/nvd/tools/wfn/fsb.go new file mode 100644 index 0000000000..7bac5626ae --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/fsb.go @@ -0,0 +1,166 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "strings" + "unicode" +) + +// BindToFmtString binds WFN to formatted string +func (a Attributes) BindToFmtString() string { + parts := make([]string, 11) + for i, s := range []string{ + a.Part, + a.Vendor, + a.Product, + a.Version, + a.Update, + a.Edition, + a.Language, + a.SWEdition, + a.TargetSW, + a.TargetHW, + a.Other, + } { + parts[i] = bindValueFS(s) + } + return fsbPrefix + strings.Join(parts, ":") +} + +// UnbindFmtString loads WFN from formatted string +func UnbindFmtString(s string) (*Attributes, error) { + if !strings.HasPrefix(s, fsbPrefix) { + return nil, fmt.Errorf("bad prefix in FSB %q", s) + } + attr := &Attributes{} + for i, partN := len(fsbPrefix), 0; i < len(s); i, partN = i+1, partN+1 { + var err error + switch partN { + case 0: + attr.Part, i, err = unbindValueFSAt(s, i) + case 1: + attr.Vendor, i, err = unbindValueFSAt(s, i) + case 2: + attr.Product, i, err = unbindValueFSAt(s, i) + case 3: + attr.Version, i, err = unbindValueFSAt(s, i) + case 4: + attr.Update, i, err = unbindValueFSAt(s, i) + case 5: + attr.Edition, i, err = unbindValueFSAt(s, i) + case 6: + attr.Language, i, err = unbindValueFSAt(s, i) + case 7: + attr.SWEdition, i, err = unbindValueFSAt(s, i) + case 8: + attr.TargetSW, i, err = unbindValueFSAt(s, i) + case 9: + attr.TargetHW, i, err = unbindValueFSAt(s, i) + case 10: + attr.Other, i, err = unbindValueFSAt(s, i) + } + if err != nil { + return nil, fmt.Errorf("unbind formatted string: %v", err) + } + } + return attr, nil +} + +// StripSlashes removes escaping of punctuation characters from attribute value +func StripSlashes(s string) string { + out := make([]byte, 0, len(s)) // might be more than we need, but no reallocs + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i < len(s)-1 { + switch s[i+1] { + case '.', '_', '-': // these pass unquoted + continue + } + } + out = append(out, s[i]) + } + return string(out) +} + +func bindValueFS(s string) string { + switch s { + case Any: + return "*" + case NA: + return "-" + default: + return StripSlashes(s) + } +} + +func unbindValueFSAt(s string, at int) (string, int, error) { + if len(s)-at < 1 || s[at] == ':' { + return Any, at, fmt.Errorf("could not unbind attribute at pos %d", at) + } + if len(s)-at == 1 || s[at+1] == ':' { + switch s[at] { + case '*': + return Any, at + 1, nil + case '-': + return NA, at + 1, nil + default: + return s[at : at+1], at + 1, nil + } + } + return addSlashesAt(s, at) +} + +func addSlashesAt(s string, at int) (string, int, error) { + b := make([]byte, 0, len(s)*2) // assume a quote for every character + embedded := false + i := at + for ; i < len(s) && s[i] != ':'; i++ { + c := s[i] + if unicode.IsLetter(rune(c)) || unicode.IsDigit(rune(c)) || c == '_' { + b = append(b, c) + embedded = true + continue + } + switch c { + case '\\': + i++ + if i == len(s) { + return "", i, fmt.Errorf("unquoted '\\' at the end of the FSB fragment: %q", s) + } + b = append(b, c, s[i]) + embedded = true + case '*': + // An unquoted asterisk must appear at the beginning or end of the string + if i != at && i != len(s)-1 && s[i+1] != ':' { + return Any, i, fmt.Errorf("unquoted '*' inside the FSB fragment: %q", s) + } + b = append(b, c) + embedded = true + case '?': + if !(i == at || i == len(s)-1 || s[i+1] == ':' || // at the beginning or at the end of the string + (!embedded && i > 0 && s[i-1] == c || // not embedded and preceded by the same symbol + (embedded && s[i+1] == c))) { // embedded and followed by the same symbol + return Any, i, fmt.Errorf("unquoted '?' inside the FSB fragment %q (%t, %d)", s, embedded, i) + } + b = append(b, c) + embedded = false + default: + b = append(b, '\\', c) + embedded = true + } + } + return string(append([]byte{}, b...)), i, nil +} diff --git a/server/vulnerabilities/nvd/tools/wfn/fsb_test.go b/server/vulnerabilities/nvd/tools/wfn/fsb_test.go new file mode 100644 index 0000000000..235ae7f180 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/fsb_test.go @@ -0,0 +1,106 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "testing" +) + +func TestUnbindFmtString(t *testing.T) { + cases := []struct { + FSB string + Expect string + Fail bool + }{ + { + FSB: "cpe:2.3:a:microsoft:internet_explorer:8.0.6001:beta:*:*:*:*:*:*", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.0\.6001",update="beta",edition=ANY,language=ANY]`, + }, + { + FSB: "cpe:2.3:a:microsoft:internet_exp?????:8.*:sp?:*:*:*:*:*:*", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_exp?????",version="8\.*",update="sp?",edition=ANY,language=ANY]`, + }, + { + FSB: "cpe:2.3:a:microsoft:internet_explorer:8.*:sp?:*:*:*:*:*:*", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.*",update="sp?",edition=ANY,language=ANY]`, + }, + { + FSB: "cpe:2.3:a:hp:insight_diagnostics:7.4.0.1570:-:*:*:online:win2003:x64:*", + Expect: `wfn:[part="a",vendor="hp",product="insight_diagnostics",version="7\.4\.0\.1570",update=NA,edition=ANY,sw_edition="online",target_sw="win2003",target_hw="x64",other=ANY,language=ANY]`, + }, + { + FSB: `cpe:2.3:a:foo\\bar:big\$money:2010:*:*:*:special:ipod_touch:80gb:*`, + Expect: `wfn:[part="a",vendor="foo\\bar",product="big\$money",version="2010",update=ANY,edition=ANY,sw_edition="special",target_sw="ipod_touch",target_hw="80gb",other=ANY,language=ANY]`, + }, + { + FSB: `cpe:2.3:a:cisco:cisco_security_monitoring\`, + Fail: true, + }, + { + FSB: `cpe:2.3:a:disney:where\\'s_my_perry?_free:1.5.1:*:*:*:*:android:*:*`, + Fail: true, + }, + { + FSB: "cpe:2.3:a:hp:insight_diagnostics:7.4.*.1570:*:*:*:*:*:*", + Fail: true, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.FSB, func(t *testing.T) { + attr, err := UnbindFmtString(tc.FSB) + if err != nil { + if tc.Fail { + return + } + t.Fatalf("failed to parse FSB %q: %v", tc.FSB, err) + } + if tc.Fail { + t.Fatalf("FSB parsed successfully, despite failure was expected: %q", tc.FSB) + } + if attr.String() != tc.Expect { + t.Fatalf("expected %s\ngot %s", tc.Expect, attr) + } + }) + } +} + +func BenchmarkUnbindFmtString(t *testing.B) { + for i := 0; i < t.N; i++ { + _, _ = UnbindFmtString("cpe:2.3:a:hp:insight_diagnostics:7.4.0.1570:-:*:*:online:win2003:x64:*") + } +} + +func TestBindToFmtString(t *testing.T) { + cases := []string{ + "cpe:2.3:a:microsoft:internet_explorer:8.0.6001:beta:*:*:*:*:*:*", + "cpe:2.3:a:microsoft:internet_explorer:8.*:sp?:*:*:*:*:*:*", + "cpe:2.3:a:hp:insight_diagnostics:7.4.0.1570:-:*:*:online:win2003:x64:*", + `cpe:2.3:a:foo\\bar:big\$\*\?money:2010:*:*:*:special:ipod_touch:80gb:*`, + } + for n, c := range cases { + c := c + t.Run(fmt.Sprintf("case#%d", n), func(t *testing.T) { + attr, err := UnbindFmtString(c) + if err != nil { + t.Fatalf("failed to parse test input %q: %v", c, err) + } + if out := attr.BindToFmtString(); out != c { + t.Fatalf("expected %s\ngot %s", c, out) + } + }) + } +} diff --git a/server/vulnerabilities/nvd/tools/wfn/matcher.go b/server/vulnerabilities/nvd/tools/wfn/matcher.go new file mode 100644 index 0000000000..fca69e6e47 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/matcher.go @@ -0,0 +1,120 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +// Matcher knows whether it matches some attributes +type Matcher interface { + // Match returns attributes which match it + // if require version, then Matcher which matches all versions should return false + Match(attrs []*Attributes, requireVersion bool) (matches []*Attributes) + // Config returns all attributes that are used by in the matching process + Config() []*Attributes +} + +// Attrs is part of the Matcher interface +func (a *Attributes) Config() []*Attributes { + return []*Attributes{a} +} + +// MatchOnlyVersion checks whether version matches +func (a *Attributes) MatchOnlyVersion(attr *Attributes) bool { + if a == nil || attr == nil { + return a == attr // both are nil + } + return matchAttr(a.Version, attr.Version) +} + +// MatchWithoutVersion checks whether everything else besides the version matches +func (a *Attributes) MatchWithoutVersion(attr *Attributes) bool { + if a == nil || attr == nil { + return a == attr // both are nil + } + return matchAttr(a.Product, attr.Product) && + matchAttr(a.Vendor, attr.Vendor) && matchAttr(a.Part, attr.Part) && + matchAttr(a.Update, attr.Update) && matchAttr(a.Edition, attr.Edition) && + matchAttr(a.Language, attr.Language) && matchAttr(a.SWEdition, attr.SWEdition) && + matchAttr(a.TargetHW, attr.TargetHW) && matchAttr(a.TargetSW, attr.TargetSW) && + matchAttr(a.Other, attr.Other) +} + +// MatchAll returns a Matcher which matches only if all matchers match +func MatchAll(ms ...Matcher) Matcher { + return &multiMatcher{ms, true} +} + +// MatchAll returns a Matcher which matches if any of the matchers match +func MatchAny(ms ...Matcher) Matcher { + return &multiMatcher{ms, false} +} + +// DontMatch returns a Matcher which matches if the given matchers doesn't +func DontMatch(m Matcher) Matcher { + return notMatcher{m} +} + +type multiMatcher struct { + matchers []Matcher + // if true, match will only return something if all matchers matched at least something + allMatch bool +} + +// Match is part of the Matcher interface +func (mm *multiMatcher) Match(attrs []*Attributes, requireVersion bool) []*Attributes { + matched := make(map[*Attributes]bool) + for _, matcher := range mm.matchers { + matches := matcher.Match(attrs, requireVersion) + if mm.allMatch && len(matches) == 0 { + // all matchers need to match at least one attr + return nil + } + for _, m := range matches { + matched[m] = true + } + } + + matches := make([]*Attributes, 0, len(matched)) + for m := range matched { + matches = append(matches, m) + } + return matches +} + +// Attrs is part of the Matcher interface +func (mm *multiMatcher) Config() []*Attributes { + var attrs []*Attributes + for _, matcher := range mm.matchers { + attrs = append(attrs, matcher.Config()...) + } + return attrs +} + +type notMatcher struct { + Matcher +} + +// Match is part of the Matcher interface +func (nm notMatcher) Match(attrs []*Attributes, requireVersion bool) (matches []*Attributes) { + matched := make(map[*Attributes]bool) + for _, m := range nm.Matcher.Match(attrs, requireVersion) { + matched[m] = true + } + + for _, a := range attrs { + if !matched[a] { + matches = append(matches, a) + } + } + return matches +} diff --git a/server/vulnerabilities/nvd/tools/wfn/matching.go b/server/vulnerabilities/nvd/tools/wfn/matching.go new file mode 100644 index 0000000000..cdb999fb40 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/matching.go @@ -0,0 +1,391 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "errors" + "fmt" +) + +// Possible values of Relation type +const ( + Disjoint Relation = iota + Subset + Equal + Superset +) + +// HasWildcard returns true if attribute has a wildcard symbol in it +func HasWildcard(s string) bool { + for n, r := range s { + if r != '*' && r != '?' { + continue + } + quoted := false + for i := n - 1; i >= 0; i-- { + if s[i] != '\\' { + break + } + quoted = !quoted + } + if !quoted { + return true + } + } + return false +} + +// Relation describes four possible set relations of wfns attribute-value +type Relation int + +// String return human readable representation of Relation value +func (r Relation) String() string { + switch r { + case Disjoint: + return "DISJOINT" + case Subset: + return "SUBSET" + case Equal: + return "EQUAL" + case Superset: + return "SUPERSET" + default: + return fmt.Sprintf("Undefined value %d", r) + } +} + +// Comparison is the result of CPE name matching +type Comparison struct { + Part Relation + Vendor Relation + Product Relation + Version Relation + Update Relation + Edition Relation + Language Relation + SWEdition Relation + TargetSW Relation + TargetHW Relation + Other Relation +} + +// IsDisjoint returns true if the result CPE name matching is disjoint +func (c Comparison) IsDisjoint() bool { + switch { + case c.Part == Disjoint: + return true + case c.Vendor == Disjoint: + return true + case c.Product == Disjoint: + return true + case c.Version == Disjoint: + return true + case c.Update == Disjoint: + return true + case c.Edition == Disjoint: + return true + case c.Language == Disjoint: + return true + case c.SWEdition == Disjoint: + return true + case c.TargetSW == Disjoint: + return true + case c.TargetHW == Disjoint: + return true + case c.Other == Disjoint: + return true + default: + return false + } +} + +// IsEqual returns true if the result CPE name matching is equal +func (c Comparison) IsEqual() bool { + switch { + case c.Part != Equal: + return false + case c.Vendor != Equal: + return false + case c.Product != Equal: + return false + case c.Version != Equal: + return false + case c.Update != Equal: + return false + case c.Edition != Equal: + return false + case c.Language != Equal: + return false + case c.SWEdition != Equal: + return false + case c.TargetSW != Equal: + return false + case c.TargetHW != Equal: + return false + case c.Other != Equal: + return false + default: + return true + } +} + +// IsSubset returns true if the result CPE name matching is a subset relation +func (c Comparison) IsSubset() bool { + switch { + case c.Part != Equal && c.Part != Subset: + return false + case c.Vendor != Equal && c.Vendor != Subset: + return false + case c.Product != Equal && c.Product != Subset: + return false + case c.Version != Equal && c.Version != Subset: + return false + case c.Update != Equal && c.Update != Subset: + return false + case c.Edition != Equal && c.Edition != Subset: + return false + case c.Language != Equal && c.Language != Subset: + return false + case c.SWEdition != Equal && c.SWEdition != Subset: + return false + case c.TargetSW != Equal && c.TargetSW != Subset: + return false + case c.TargetHW != Equal && c.TargetHW != Subset: + return false + case c.Other != Equal && c.Other != Subset: + return false + default: + return true + } +} + +// IsSuperset returns true if the result CPE name matching is a superset relation +func (c Comparison) IsSuperset() bool { + switch { + case c.Part != Equal && c.Part != Superset: + return false + case c.Vendor != Equal && c.Vendor != Superset: + return false + case c.Product != Equal && c.Product != Superset: + return false + case c.Version != Equal && c.Version != Superset: + return false + case c.Update != Equal && c.Update != Superset: + return false + case c.Edition != Equal && c.Edition != Superset: + return false + case c.Language != Equal && c.Language != Superset: + return false + case c.SWEdition != Equal && c.SWEdition != Superset: + return false + case c.TargetSW != Equal && c.TargetSW != Superset: + return false + case c.TargetHW != Equal && c.TargetHW != Superset: + return false + case c.Other != Equal && c.Other != Superset: + return false + default: + return true + } +} + +// Relation returns relation between matched CPE names +func (c Comparison) Relation() Relation { + if c.IsSubset() { + return Subset + } + if c.IsEqual() { + return Equal + } + if c.IsSuperset() { + return Superset + } + return Disjoint +} + +// Compare performs comparison of each attribute-value (A-V) of the wfns +// as per Name Matching Specification v.2.3 and returns the set relation between +// source and target attribute-values. +// The table below illustrates a set of source and target A-Vs and the resulting set of attribute +// comparison relations. +// +--------------------------------------------+------------------------------------+ +// | Attribute Relation Set | Name Comparison Relation | +// +--------------------------------------------+------------------------------------+ +// | any attribute relation is != | CPE name relation is DISJOINT (!=) | +// | all attribute relations are == | CPE name relation is EQAL (==) | +// | all attribute relations are Subset or == | CPE name relation is Subset | +// | all attribute relations are Superset or == | CPE name relation is Superset | +// +--------------------------------------------+------------------------------------+ +func Compare(src, tgt *Attributes) (Comparison, error) { + var result Comparison + var err error + if result.Part, err = CompareAttr(src.Part, tgt.Part); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Part, tgt.Part, err) + } + if result.Vendor, err = CompareAttr(src.Vendor, tgt.Vendor); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Vendor, tgt.Vendor, err) + } + if result.Product, err = CompareAttr(src.Product, tgt.Product); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Product, tgt.Product, err) + } + if result.Version, err = CompareAttr(src.Version, tgt.Version); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Version, tgt.Version, err) + } + if result.Update, err = CompareAttr(src.Update, tgt.Update); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Update, tgt.Update, err) + } + if result.Edition, err = CompareAttr(src.Edition, tgt.Edition); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Edition, tgt.Edition, err) + } + if result.Language, err = CompareAttr(src.Language, tgt.Language); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Language, tgt.Language, err) + } + if result.SWEdition, err = CompareAttr(src.SWEdition, tgt.SWEdition); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.SWEdition, tgt.SWEdition, err) + } + if result.TargetSW, err = CompareAttr(src.TargetSW, tgt.TargetSW); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.TargetSW, tgt.TargetSW, err) + } + if result.TargetHW, err = CompareAttr(src.TargetHW, tgt.TargetHW); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.TargetHW, tgt.TargetHW, err) + } + if result.Other, err = CompareAttr(src.Other, tgt.Other); err != nil { + return result, fmt.Errorf("failed to compare wfns %q to %q: %v", src.Other, tgt.Other, err) + } + return result, nil +} + +// Match returns false if the src and tgt attributes are disjoint. +// Undefined relations between attributes (see CompareAttr) are considered to be disjoint, +// except when source attribute matches target attribute byte-by-byte. +func Match(src, tgt *Attributes) bool { + if src == nil || tgt == nil { + return false + } + return matchAttr(src.Part, tgt.Part) && matchAttr(src.Vendor, tgt.Vendor) && + matchAttr(src.Product, tgt.Product) && matchAttr(src.Version, tgt.Version) && + matchAttr(src.Update, tgt.Update) && matchAttr(src.Edition, tgt.Edition) && + matchAttr(src.Language, tgt.Language) && matchAttr(src.SWEdition, tgt.SWEdition) && + matchAttr(src.TargetHW, tgt.TargetHW) && matchAttr(src.TargetSW, tgt.TargetSW) && + matchAttr(src.Other, tgt.Other) +} + +// CompareAttr calculates a relation between a pair of wfn attribute values. +// Accordingly to standard, string matching must be insensitive to lexical case, +// target A-V must not have wildcards. +// The table below defines possible set relations for each comparison +// ANY and NA are logical values as defined per [CPE23-N:5.3.1] +// i and k are wildcard-free attribute-value strings that are not identical, e.g. i is "foo" and k is "bar" +// m + w is attribute-value string containing a legal combination of unquoted question mark or asterisk wildcards +// +// at the beginning and/or the end of the string, e.g. "*b??" +// Enumeration of +// Attribute Comparison Set Relations +// +// +------------+------------+--------------+ +// | Source A-V | Target A-V | Relation | +// +------------+------------+--------------+ +// | ANY | ANY | == | +// | ANY | NA | Superset | +// | ANY | i | Superset | +// | ANY | m + w | undef | +// | NA | ANY | Subset | +// | NA | NA | == | +// | NA | i | != | +// | NA | m + w | undefined | +// | i | i | == | +// | i | k | != | +// | i | m + w | undefined | +// | i | NA | != | +// | i | ANY | Subset | +// | m1 + w | m2 | Subset or != | +// | m + w | ANY | Subset | +// | m1 + w | NA | != | +// | m1 + w | m2 + w | undefined | +// +----------------------------------------+ +func CompareAttr(src, tgt string) (Relation, error) { + if src != NA && src != Any && HasWildcard(tgt) { + return Disjoint, errors.New("target attribute value cannot contain wildcard") + } + if src == tgt { + return Equal, nil + } + if src == Any { + return Superset, nil + } + if tgt == Any { + return Subset, nil + } + if src == NA || tgt == NA { + return Disjoint, nil + } + return matchStr(src, tgt), nil +} + +// matchAttr returns true if relation between src and tgt is one of Equal, Subset or Superset. +// It returns false on undefined relations, except when src == tgt byte-by-byte. +// This is crude but fast(-er) version of CompareAttr. +func matchAttr(src, tgt string) bool { + switch { + case src == Any || tgt == Any || src == tgt: + return true + case src == NA || tgt == NA || HasWildcard(tgt): + return false + default: + return matchStr(src, tgt) != Disjoint + } +} + +func matchStr(s, t string) Relation { + escaped := false + matchesAs := Equal + idx := 0 + for ; idx < len(t); idx++ { + if idx >= len(s) { + return Disjoint + } + if !escaped && s[idx] == '*' { + if idx == len(s)-1 { + return Superset + } + for i := idx; i < len(t); i++ { + if matchStr(s[idx+1:], t[i:]) != Disjoint { + return Superset + } + } + return Disjoint + } + + if (escaped || s[idx] != '?') && s[idx] != t[idx] { + return Disjoint + } else if !escaped && s[idx] == '?' { + matchesAs = Superset + } + if s[idx] == '\\' { + escaped = !escaped + } else { + escaped = false + } + } + for ; idx < len(s); idx++ { + if s[idx] != '*' { + return Disjoint + } + } + if len(s) > len(t) { + return Superset + } + return matchesAs +} diff --git a/server/vulnerabilities/nvd/tools/wfn/matching_test.go b/server/vulnerabilities/nvd/tools/wfn/matching_test.go new file mode 100644 index 0000000000..63806e7b12 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/matching_test.go @@ -0,0 +1,185 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "testing" +) + +func TestHasWildcard(t *testing.T) { + cases := []struct { + Src string + Expect bool + }{ + {"", false}, + {"foo", false}, + {"bar*", true}, + {"?baz", true}, + {`\\\\*foo`, true}, + {`bar\\\?`, false}, + {`foo\bar*`, true}, + {`b\?r?`, true}, + } + for _, c := range cases { + t.Run(c.Src, func(t *testing.T) { + r := HasWildcard(c.Src) + if r != c.Expect { + t.Fatalf("HasWildcard(%q) returned %v, %v was expected", c.Src, r, c.Expect) + } + }) + } +} + +func TestMatchStr(t *testing.T) { + cases := []struct { + Src string + Tgt string + Expect Relation + }{ + {"foo", "bar", Disjoint}, + {"bar", "bar", Equal}, + {"*", "foo", Superset}, + {"*a?", "bar", Superset}, + {"*", "", Superset}, + {"f*", "foo", Superset}, + {"ba?", "bar", Superset}, + {"fo??", "foo", Disjoint}, + {"foo*", "foo", Superset}, + {"*bar", "bar", Superset}, + {"??o", "foo", Superset}, + {"??o", "bar", Disjoint}, + {"boo\\?", "boo\\?", Equal}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%q vs %q", c.Src, c.Tgt), func(t *testing.T) { + r := matchStr(c.Src, c.Tgt) + if r != c.Expect { + t.Fatalf("matchStr returned %v, %v was expected", r, c.Expect) + } + }) + } +} + +func TestCompare(t *testing.T) { + cases := []struct { + Src string + Tgt string + Fail bool + Expect Relation + }{ + { + Src: `cpe:2.3:a:microsoft:internet_explorer:8.*:sp?:*:*:*:*:*:*`, + Tgt: `cpe:2.3:a:microsoft:internet_explorer:8.0.6001:sp3:*:*:*:*:*:*`, + Expect: Superset, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%q vs %q", c.Src, c.Tgt), func(t *testing.T) { + srcAttr, err := UnbindFmtString(c.Src) + if err != nil { + t.Fatalf("failed to unbind WFN from FSB %q: %v", c.Src, err) + } + tgtAttr, err := UnbindFmtString(c.Tgt) + if err != nil { + t.Fatalf("failed to unbind WFN from FSB %q: %v", c.Tgt, err) + } + r, err := Compare(srcAttr, tgtAttr) + if c.Fail && err == nil { + t.Fatal("test was expected to fail, but succeeded") + } + if !c.Fail && err != nil { + t.Fatalf("test was expected to succeed, but failed: %v", err) + } + if r.Relation() != c.Expect { + t.Fatalf("Compare returned %v (%v), %v was expected", r.Relation(), r, c.Expect) + } + }) + } +} + +func BenchmarkCompare(b *testing.B) { + src := `cpe:2.3:a:microsoft:*internet_ex??????:8.0.*:sp?:*:*:*:*:*:*` + tgt := `cpe:2.3:a:microsoft:internet_explorer:8.1.6001:sp3:*:*:*:*:*:*` + srcAttr, err := UnbindFmtString(src) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", src, err) + } + tgtAttr, err := UnbindFmtString(tgt) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", tgt, err) + } + for i := 0; i < b.N; i++ { + // checking error and result adds about 10% of runtime to this benchmark on my machine + // and correctness is covered by tests, so skip it + _, _ = Compare(srcAttr, tgtAttr) + } +} + +func BenchmarkMatch(b *testing.B) { + src := `cpe:2.3:a:microsoft:*internet_ex??????:8.0.*:sp?:*:*:*:*:*:*` + tgt := `cpe:2.3:a:microsoft:internet_explorer:8.1.6001:sp3:*:*:*:*:*:*` + srcAttr, err := UnbindFmtString(src) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", src, err) + } + tgtAttr, err := UnbindFmtString(tgt) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", tgt, err) + } + for i := 0; i < b.N; i++ { + // checking error and result adds about 10% of runtime to this benchmark on my machine + // and correctness is covered by tests, so skip it + Match(srcAttr, tgtAttr) + } +} + +func BenchmarkIsDisjoint(b *testing.B) { + src := `cpe:2.3:a:microsoft:*internet_ex??????:8.*:sp?:*:*:*:*:*:1` + tgt := `cpe:2.3:a:microsoft:internet_explorer:8.0.6001:sp3:*:*:*:*:*:2` + srcAttr, err := UnbindFmtString(src) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", src, err) + } + tgtAttr, err := UnbindFmtString(tgt) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", tgt, err) + } + cmp, _ := Compare(srcAttr, tgtAttr) + for i := 0; i < b.N; i++ { + cmp.IsDisjoint() + } +} + +func BenchmarkHasWildcard(b *testing.B) { + tests := map[string]string{ + "has": `cpe:2.3:a:microsoft:*internet_ex??????:8.*:sp?:*:*:*:*:*:*`, + "has not": `cpe:2.3:a:microsoft:internet_explorer:8.0:sp2:*:*:*:*:*:*`, + "has escaped": `cpe:2.3:a:vendor\?:product\?:8.0:sp2:*:*:*:*:*:*`, + } + for tag, test := range tests { + b.Run(tag, func(b *testing.B) { + srcAttr, err := UnbindFmtString(test) + if err != nil { + b.Fatalf("failed to unbind WFN from FSB %q: %v", test, err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + HasWildcard(srcAttr.Vendor) + HasWildcard(srcAttr.Product) + } + }) + } +} diff --git a/server/vulnerabilities/nvd/tools/wfn/uri.go b/server/vulnerabilities/nvd/tools/wfn/uri.go new file mode 100644 index 0000000000..5e2f502f5f --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/uri.go @@ -0,0 +1,346 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "strconv" + "strings" +) + +// BindToURI binds WFN to URI +func (a Attributes) BindToURI() string { + var parts []string + for i, v := range []string{ + a.Part, + a.Vendor, + a.Product, + a.Version, + a.Update, + "", + a.Language, + } { + if i != 5 { // other than edition + parts = append(parts, bindValueURI(v)) + continue + } + edParts := make([]string, 5) + allNAs := true + for i, v2 := range []string{a.Edition, a.SWEdition, a.TargetSW, a.TargetHW, a.Other} { + edParts[i] = bindValueURI(v2) + if edParts[i] != "-" { + allNAs = false + } + } + if allNAs { + parts = append(parts, "-") + } else { + parts = append(parts, pack(edParts)) + } + } + // empty elements at the end of the URI should be omitted + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + break + } + parts = parts[:i] + } + return uriPrefix + strings.Join(parts, ":") +} + +// UnbindURI loads WFN from URI +func UnbindURI(s string) (*Attributes, error) { + if !strings.HasPrefix(s, uriPrefix) { + return nil, fmt.Errorf("unbind uri: bad prefix in URI %q", s) + } + s = strings.ToLower(s[len(uriPrefix):]) // reject schema prefix + normalize + attr := Attributes{} + var err error + for i, partN := 0, 0; i < len(s); i, partN = i+1, partN+1 { + switch partN { + case 0: + attr.Part, i, err = unbindValueURIAtTill(s, i, ':') + case 1: + attr.Vendor, i, err = unbindValueURIAtTill(s, i, ':') + case 2: + attr.Product, i, err = unbindValueURIAtTill(s, i, ':') + case 3: + attr.Version, i, err = unbindValueURIAtTill(s, i, ':') + case 4: + attr.Update, i, err = unbindValueURIAtTill(s, i, ':') + case 5: + if s[i] != '~' { + attr.Edition, i, err = unbindValueURIAtTill(s, i, ':') + break + } + i++ + edition23: + for subpartN := 0; i < len(s); i, subpartN = i+1, subpartN+1 { + switch subpartN { + case 0: + attr.Edition, i, err = unbindValueURIAtTill(s, i, '~') + case 1: + attr.SWEdition, i, err = unbindValueURIAtTill(s, i, '~') + case 2: + attr.TargetSW, i, err = unbindValueURIAtTill(s, i, '~') + case 3: + attr.TargetHW, i, err = unbindValueURIAtTill(s, i, '~') + case 4: + attr.Other, i, err = unbindValueURIAtTill(s, i, ':') + default: + break edition23 + } + } + case 6: + attr.Language, i, err = unbindValueURIAtTill(s, i, ':') + } + if err != nil { + return nil, fmt.Errorf("unbind uri: %v", err) + } + } + return &attr, nil +} + +func pack(ss []string) string { + compat := true + for _, s := range ss[1:] { + if s != "" { + compat = false + break + } + } + if compat { + return ss[0] + } + return "~" + strings.Join(ss, "~") +} + +// Scans an input string s and applies the following transformations: +// - pass alphanumeric characters thru untouched +// - percent-encode quoted non-alphanumerics as needed +// - unquoted special characters are mapped to their special forms. +func bindValueURI(s string) string { + var out []byte + switch s { + case NA: + return "-" + case Any: + return "" + } + for i := 0; i < len(s); i++ { + b := s[i] + if b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b == '_' { + // alnum + '_' pass untouched + out = append(out, b) + } else if b == '\\' { + // percent-encode escaped characters + // sanity check should be done during unbinding, so here we silently skip all + // illegal characters + i++ + if i == len(s) { + break + } + out = append(out, pctEncode(s[i])...) + } else if b == '?' { // unquoted '?' -> "%01" + out = append(out, '%', '0', '1') + } else if b == '*' { // unquoted '*' -> "%02" + out = append(out, '%', '0', '2') + } + } + return string(out) +} + +func unbindValueURIAtTill(s string, at int, till byte) (string, int, error) { + if at >= len(s) || s[at] == till { + return Any, at, nil + } + if s[at] == '-' { + return NA, at + 1, nil + } + out := make([]byte, 0, len(s)*2) // assume the worst + embedded := false + i := at +loop: + for ; i < len(s); i++ { + switch s[i] { + case till: + break loop + case '%': + if i+3 > len(s) { + return "", i, fmt.Errorf("unbind URI attribute: illegal percent-encoded value at %d: %q", i, s[i:]) + } + codeStr := s[i : i+3] + code, err := strconv.ParseInt(s[i+1:i+3], 16, 8) + if err != nil { + return "", i, fmt.Errorf("unbind URI attribute: illegal percent-encoded value at %d: %q", i, s[i+1:i+3]) + } + if code == 0x1 || code == 0x2 { + if !(i == at || i == len(s)-3 || s[i+3] == till || // at the beginning or at the end of the string + (!embedded && i > 2 && s[i-3:i] == codeStr || // not embedded and preceded by the same symbol + (embedded && i+6 < len(s) && s[i+3:i+6] == codeStr))) { // embedded and followed by the same symbol + return "", i, fmt.Errorf("unbind URI attribute: %%%02d is embedded into string %q", code, s) + } + switch code { + case 0x1: + out = append(out, '?') + case 0x2: + out = append(out, '*') + } + i += 2 + break + } + switch code { + case 0x21: + out = append(out, '\\', '!') + case 0x22: + out = append(out, '\\', '"') + case 0x23: + out = append(out, '\\', '#') + case 0x24: + out = append(out, '\\', '$') + case 0x25: + out = append(out, '\\', '%') + case 0x26: + out = append(out, '\\', '&') + case 0x27: + out = append(out, '\\', '\'') + case 0x28: + out = append(out, '\\', '(') + case 0x29: + out = append(out, '\\', ')') + case 0x2a: + out = append(out, '\\', '*') + case 0x2b: + out = append(out, '\\', '+') + case 0x2c: + out = append(out, '\\', ',') + case 0x2f: + out = append(out, '\\', '/') + case 0x3a: + out = append(out, '\\', ':') + case 0x3b: + out = append(out, '\\', ';') + case 0x3c: + out = append(out, '\\', '<') + case 0x3d: + out = append(out, '\\', '=') + case 0x3e: + out = append(out, '\\', '>') + case 0x3f: + out = append(out, '\\', '?') + case 0x40: + out = append(out, '\\', '@') + case 0x5b: + out = append(out, '\\', '[') + case 0x5c: + out = append(out, '\\', '\\') + case 0x5d: + out = append(out, '\\', ']') + case 0x5e: + out = append(out, '\\', '^') + case 0x60: + out = append(out, '\\', '`') + case 0x7b: + out = append(out, '\\', '{') + case 0x7c: + out = append(out, '\\', '|') + case 0x7d: + out = append(out, '\\', '}') + case 0x7e: + out = append(out, '\\', '~') + default: + return "", i, fmt.Errorf("unbind URI attribute: illegal percent-encoded value %q", s[i+1:i+3]) + } + i += 2 + embedded = true + case '.', '-', '~': + out = append(out, '\\', s[i]) + embedded = true + default: + out = append(out, s[i]) + embedded = true + } + } + return string(out), i, nil +} + +func pctEncode(b byte) []byte { + switch b { + case '!': + return []byte("%21") + case '"': + return []byte("%22") + case '#': + return []byte("%23") + case '$': + return []byte("%24") + case '%': + return []byte("%25") + case '&': + return []byte("%26") + case '\'': + return []byte("%27") + case '(': + return []byte("%28") + case ')': + return []byte("%29") + case '*': + return []byte("%2a") + case '+': + return []byte("%2b") + case ',': + return []byte("%2c") + case '-': + return []byte("-") // bound without encoding + case '.': + return []byte(".") // bound without encoding + case '/': + return []byte("%2f") + case ':': + return []byte("%3a") + case ';': + return []byte("%3b") + case '<': + return []byte("%3c") + case '=': + return []byte("%3d") + case '>': + return []byte("%3e") + case '?': + return []byte("%3f") + case '@': + return []byte("%40") + case '[': + return []byte("%5b") + case '\\': + return []byte("%5c") + case ']': + return []byte("%5d") + case '^': + return []byte("%5e") + case '`': + return []byte("%60") + case '{': + return []byte("%7b") + case '|': + return []byte("%7c") + case '}': + return []byte("%7d") + case '~': + return []byte("%7e") + default: + return []byte{b} + } +} diff --git a/server/vulnerabilities/nvd/tools/wfn/uri_test.go b/server/vulnerabilities/nvd/tools/wfn/uri_test.go new file mode 100644 index 0000000000..ddb33d1181 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/uri_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "testing" +) + +func TestUnbindURI(t *testing.T) { + cases := []struct { + URI string + Expect string + Fail bool + }{ + { + URI: "cpe:/a", + Expect: `wfn:[part="a",vendor=ANY,product=ANY,version=ANY,update=ANY,edition=ANY,language=ANY]`, + }, + { + URI: "cpe:/a:microsoft:internet_explorer:8.0.6001:beta", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.0\.6001",update="beta",edition=ANY,language=ANY]`, + }, + { + URI: "cpe:/a:microsoft:internet_explorer:8.%2a:sp%3f", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.\*",update="sp\?",edition=ANY,language=ANY]`, + }, + { + URI: "cpe:/a:microsoft:internet_explorer:8.%02:sp%01", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.*",update="sp?",edition=ANY,language=ANY]`, + }, + { + URI: "cpe:/a:Microsoft:internet_explorer:8.%02:sp%01:limited", + Expect: `wfn:[part="a",vendor="microsoft",product="internet_explorer",version="8\.*",update="sp?",edition="limited",language=ANY]`, + }, + { + URI: "cpe:/a:hp:insight_diagnostics:7.4.0.1570::~~online~win2003~x64~", + Expect: `wfn:[part="a",vendor="hp",product="insight_diagnostics",version="7\.4\.0\.1570",update=ANY,edition=ANY,sw_edition="online",target_sw="win2003",target_hw="x64",other=ANY,language=ANY]`, + }, + { + URI: "cpe:/o:microsoft:windows_10:-::~~~~x64~", + Expect: `wfn:[part="o",vendor="microsoft",product="windows_10",version=NA,update=ANY,edition=ANY,sw_edition=ANY,target_sw=ANY,target_hw="x64",other=ANY,language=ANY]`, + }, + { + URI: `cpe:/a:foo:boo%02%02`, + Fail: true, + }, + { + URI: "cpe:/a:foo:bar:12.%02.1234", + Fail: true, + }, + } + for _, tc := range cases { + t.Run(tc.URI, func(t *testing.T) { + attr, err := UnbindURI(tc.URI) + if err != nil { + if tc.Fail { + return + } + t.Fatalf("failed to parse URI %q: %v", tc.URI, err) + } + if tc.Fail { + t.Fatalf("URI parsed successfully, despite failure was expected: %q", tc.URI) + } + if attr.String() != tc.Expect { + t.Fatalf("expected %s\ngot %s", tc.Expect, attr) + } + }) + } +} + +func BenchmarkUnbindURI(t *testing.B) { + for i := 0; i < t.N; i++ { + _, _ = UnbindURI("cpe:/a:hp:insight_diagnostics:7.4.0.1570::~~online~win2003~x64~") + } +} + +func TestBindToURI(t *testing.T) { + cases := []string{ + "cpe:/a:microsoft:internet_explorer:8.0.6001:beta", + "cpe:/a:microsoft:internet_explorer:8.%2a:sp%3f", + "cpe:/a:microsoft:internet_explorer:8.%02:sp%01", + "cpe:/a:microsoft:internet_explorer:8.%02:sp%01:limited", + "cpe:/a:hp:insight_diagnostics:7.4.0.1570::~~online~win2003~x64~", + } + for n, c := range cases { + c := c + t.Run(fmt.Sprintf("case#%d", n), func(t *testing.T) { + attr, err := UnbindURI(c) + if err != nil { + t.Fatalf("failed to parse input %q: %v", c, err) + } + if out := attr.BindToURI(); out != c { + t.Fatalf("expected %s\ngot %s", c, out) + } + }) + } +} diff --git a/server/vulnerabilities/nvd/tools/wfn/wfn.go b/server/vulnerabilities/nvd/tools/wfn/wfn.go new file mode 100644 index 0000000000..0cce686050 --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/wfn.go @@ -0,0 +1,163 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "fmt" + "strings" +) + +// KnownParts is a map of known WFN attribute parts. +var KnownParts = map[string]string{ + "a": "application", + "o": "operating system", + "h": "hardware", +} + +// Possible logical value of Attributes +// empty string considered ANY when parsing and unquoted "-" is illegal in WFN attribute-value +const ( + Any = "" + NA = "-" +) + +const ( + uriPrefix = "cpe:/" + fsbPrefix = "cpe:2.3:" +) + +var parsers = map[string]func(s string) (*Attributes, error){ + uriPrefix: UnbindURI, + fsbPrefix: UnbindFmtString, +} + +// Parse parses Attributes from URI or formatted string binding. +func Parse(s string) (*Attributes, error) { + for prefix, parserFunc := range parsers { + if strings.HasPrefix(s, prefix) { + return parserFunc(s) + } + } + return nil, fmt.Errorf("wfn: unsupported format %q", s) +} + +// Attributes defines the WFN Data Model Attributes. +type Attributes struct { + Part string + Vendor string + Product string + Version string + Update string + Edition string + SWEdition string + TargetSW string + TargetHW string + Other string + Language string +} + +// NewAttributesWithNA allocates Attributes object with all fields initialized to NA logical value +func NewAttributesWithNA() *Attributes { + return newAttributes(NA) +} + +// NewAttributesWithAny allocates Attributes object with all fields initialized to Any logical value +func NewAttributesWithAny() *Attributes { + return newAttributes(Any) +} + +func newAttributes(defaultValue string) *Attributes { + return &Attributes{ + Part: defaultValue, + Vendor: defaultValue, + Product: defaultValue, + Version: defaultValue, + Update: defaultValue, + Edition: defaultValue, + SWEdition: defaultValue, + TargetSW: defaultValue, + TargetHW: defaultValue, + Other: defaultValue, + Language: defaultValue, + } +} + +// WFNize transforms a string into CPE23-NAME compliant avstring value. +// This function isn't a part of standard. Quoted wildcards (*?) become unquoted ones (i.e. act as wildcards, +// not a literal '*' and '?') +// If wildcards are used, it is a responsibility of the user to make sure they comply with the standard, i.e. +// only appear at the beginning or at the end of the string and, in case of asterisk, only once in each case. +// Uppercase letters are valid avstring characters, but they are rarely (if ever) used in WFNs. It is recommended +// to strings.ToLower() the string before passing it to this function. +func WFNize(s string) (string, error) { + const allowedPunct = "-!\"#$%&'()+,./:;<=>@[]^`{|}!~" + // replace spaces with underscores + in := strings.Replace(s, " ", "_", -1) + buf := make([]byte, 0, len(in)) + // remove illegal characters + for n, c := range in { + c := byte(c) + if c >= 'A' && c <= 'Z' || + c >= 'a' && c <= 'z' || + c >= '0' && c <= '9' || + c == '_' || + strings.IndexByte(allowedPunct, c) != -1 { + buf = append(buf, c) + } + // handle wildcard characters + if c == '*' || c == '?' { + if n == 0 || in[n-1] != '\\' { + buf = append(buf, '\\') + } + buf = append(buf, c) + } + } + // quote everything that requires quoting + s, _, err := addSlashesAt(string(buf), 0) + return s, err +} + +// String returns a string representation of the wfn +func (a Attributes) String() string { + parts := make([]string, 0, 11) + // these are always displayed + parts = append(parts, keyValueString("part", a.Part)) + parts = append(parts, keyValueString("vendor", a.Vendor)) + parts = append(parts, keyValueString("product", a.Product)) + parts = append(parts, keyValueString("version", a.Version)) + parts = append(parts, keyValueString("update", a.Update)) + parts = append(parts, keyValueString("edition", a.Edition)) + // these are present only if one of them isn't ANY (cpe:2.2 compartibility) + if a.SWEdition != Any || a.TargetHW != Any || a.TargetSW != Any || a.Other != Any { + parts = append(parts, keyValueString("sw_edition", a.SWEdition)) + parts = append(parts, keyValueString("target_sw", a.TargetSW)) + parts = append(parts, keyValueString("target_hw", a.TargetHW)) + parts = append(parts, keyValueString("other", a.Other)) + } + // also always displayed + parts = append(parts, keyValueString("language", a.Language)) + return fmt.Sprintf("wfn:[%s]", strings.Join(parts, ",")) +} + +func keyValueString(k, v string) string { + switch v { + case Any: + return fmt.Sprintf("%s=ANY", k) + case NA: + return fmt.Sprintf("%s=NA", k) + default: + return fmt.Sprintf("%s=\"%s\"", k, v) + } +} diff --git a/server/vulnerabilities/nvd/tools/wfn/wfn_test.go b/server/vulnerabilities/nvd/tools/wfn/wfn_test.go new file mode 100644 index 0000000000..63cd33450f --- /dev/null +++ b/server/vulnerabilities/nvd/tools/wfn/wfn_test.go @@ -0,0 +1,52 @@ +// Copyright (c) Facebook, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wfn + +import ( + "testing" +) + +func TestWFNize(t *testing.T) { + cases := []struct { + in string + expected string + expectErr bool + }{ + {"Zonealarm Wireless Security", "Zonealarm_Wireless_Security", false}, + {"1.8.14.6001", `1\.8\.14\.6001`, false}, + {"xorg-server", `xorg\-server`, false}, + {`1.8.\*`, `1\.8\.*`, false}, + {"1.*.14", `1\.\*\.14`, false}, + {`1.\*.14`, "", true}, + } + for _, c := range cases { + res, err := WFNize(c.in) + if err != nil { + if !c.expectErr { + t.Errorf("WFNize(%q) returned error: %v", c.in, err) + } + } else if c.expectErr { + t.Errorf("WFNize(%q) was expected to fail, but succedeed", c.in) + } else if res != c.expected { + t.Errorf("WFNize(%q) returned %q, %q was expected", c.in, res, c.expected) + } + } +} + +func BenchmarkWFNize(t *testing.B) { + for i := 0; i < t.N; i++ { + _, _ = WFNize("1.8.14.6001") + } +} From 17d40562b77833fcc2bbfc50ca368c11648a2b4d Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:26:08 -0700 Subject: [PATCH 14/83] Update frontend pattern doc (#18225) - Update frontend patterns doc per discussion during today's sync --------- Co-authored-by: Jacob Shandling Co-authored-by: RachelElysia --- frontend/docs/patterns.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index 20406cce17..ebea4d4212 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -11,12 +11,14 @@ should be discussed within the team and documented before merged. - [Typing](#typing) - [Utilities](#utilities) - [Components](#components) -- [React Hooks](#react-hooks) +- [React hooks](#react-hooks) - [React Context](#react-context) -- [Fleet API Calls](#fleet-api-calls) -- [Page Routing](#page-routing) +- [Fleet API calls](#fleet-api-calls) +- [Page routing](#page-routing) - [Styles](#styles) -- [Icons and Images](#icons-and-images) +- [Icons and images](#icons-and-images) +- [Testing](#testing) +- [Security considerations](#security-considerations) - [Other](#other) ## Typing @@ -344,9 +346,9 @@ Below are a few need-to-knows about what's available in Fleet's CSS: action buttons (cancel, save, delete, etc.) and proceed to style as needed. -## Icons and Images +## Icons and images -### Adding Icons +### Adding icons To add a new icon: @@ -373,6 +375,20 @@ The icon should now be available to use with the `Icon` component from the given The recommend line limit per page/component is 500 lines. This is only a recommendation. Larger files are to be split into multiple files if possible. + +## Testing + +At a bare minimum, we make every effort to test that components that should render data are doing so +as expected. For example: `HQRTable.tests.tsx` tests that the `HQRTable` component correctly renders +data being passed to it. + +At a bare minimum, critical bugs released involving the UI will have automated testing discussed at the critical bug post-mortem with a frontend engineer and an engineering manager. We make every effort to add an automated test to either the unit, integration, or E2E layer to prevent the critical bug from resurfacing. + +## Security considerations + +We make every effort to avoid using the `dangerouslySetInnerHTML` prop. When absolutely necessary to +use this prop, we make sure to sanitize any user-defined input to it with `DOMPurify.sanitize` + ## Other ### Local states From 78fbcb8f28874e09207b904404c7d55c37e6955a Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 24 Apr 2024 17:47:35 -0500 Subject: [PATCH 15/83] Website: Update personalization on endpoint ops page. (#18502) Closes: https://github.com/fleetdm/confidential/issues/6255 Changes: - Updated view-endpoint-ops to sort and exclude quotes based on a user's `primaryBuyingSituation`. - Updated the quote by Brendan Shaklovitz in testimonials.yml - Updated the endpoint ops page to hide/show videos based on a user's `primaryBuyingSituation` - --- handbook/company/testimonials.yml | 2 +- website/api/controllers/view-endpoint-ops.js | 16 +++++++++++----- website/assets/styles/pages/endpoint-ops.less | 4 ++-- website/views/pages/endpoint-ops.ejs | 14 ++++++++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index 227a42f16d..ed03143f9f 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -71,7 +71,7 @@ quoteAuthorJobTitle: Staff CPE at Stripe productCategories: [Endpoint operations, Device management] - - quote: Fleet’s come a long way - to now being the top open-source osquery manager. Just in the past 6 months. + 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 diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index e67c72cdce..c9a860a64f 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -23,13 +23,19 @@ module.exports = { // Get testimonials for the component. let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); - // Filter the testimonials by product category - testimonialsForScrollableTweets = _.filter(testimonialsForScrollableTweets, (testimonial)=>{ - return _.contains(testimonial.productCategories, 'Endpoint operations'); - }); // 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']; + 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']; + if(this.req.session.primaryBuyingSituation === 'eo-it'){ + testimonialOrderForThisPage = ['Eric Tan', 'Tom Larkin', 'Nick Fohs', 'Harrison Ravazzolo', 'Wes Whetstone', 'Chandra Majumdar', 'Erik Gomez', 'Mike Arpaia', 'Kenny Botelho']; + } else if(this.req.session.primaryBuyingSituation === 'eo-security'){ + testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar']; + } + // Filter the testimonials by product category and the filtered list we built above. + testimonialsForScrollableTweets = _.filter(testimonialsForScrollableTweets, (testimonial)=>{ + return _.contains(testimonial.productCategories, 'Endpoint operations') && _.contains(testimonialOrderForThisPage, testimonial.quoteAuthorName); + }); + testimonialsForScrollableTweets.sort((a, b)=>{ if(testimonialOrderForThisPage.indexOf(a.quoteAuthorName) === -1){ return 1; diff --git a/website/assets/styles/pages/endpoint-ops.less b/website/assets/styles/pages/endpoint-ops.less index fb766e9cd2..410f45c88b 100644 --- a/website/assets/styles/pages/endpoint-ops.less +++ b/website/assets/styles/pages/endpoint-ops.less @@ -242,14 +242,14 @@ &:hover { box-shadow: 0px 4px 16px 0px #E2E4EA; } - &:first-of-type { + &.austin-anderson { background: url('/images/video-testimonial-thumbnail-austin-anderson-223x168@2x.jpg'); background-position: center; background-size: cover; margin-right: 12px; margin-left: 0px; } - &:last-of-type { + &.nick-fohs { background: url('/images/video-testimonial-thumbnail-nick-fohs-223x168@2x.png'); background-position: center; background-size: cover; diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index 30a77b3b97..59b640323c 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -71,12 +71,22 @@ <% } %>
-
+ <%if(primaryBuyingSituation === 'eo-security'){%> +
PlayPlay video
-
+ <%} else if(primaryBuyingSituation === 'eo-it') {%> +
PlayPlay video
+ <% } else { %> +
+ PlayPlay video +
+
+ PlayPlay video +
+ <% } %>
From edf290bf549bdf24e5d43bb95e3bcc9118694bf8 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Wed, 24 Apr 2024 20:52:31 -0500 Subject: [PATCH 16/83] Adding logging level to frequent server log messages. (#18486) --- server/service/apple_mdm.go | 4 ++-- server/worker/macos_setup_assistant.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 6f6001a496..21fb5551d5 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2819,7 +2819,7 @@ func ReconcileAppleDeclarations( } if len(changedHosts) == 0 { - logger.Log("msg", "no hosts with changed declarations") + level.Info(logger).Log("msg", "no hosts with changed declarations") return nil } @@ -2828,7 +2828,7 @@ func ReconcileAppleDeclarations( return ctxerr.Wrap(ctx, err, "issuing DeclarativeManagement command") } - logger.Log("msg", "sent DeclarativeManagement command", "host_number", len(changedHosts)) + level.Info(logger).Log("msg", "sent DeclarativeManagement command", "host_number", len(changedHosts)) return nil } diff --git a/server/worker/macos_setup_assistant.go b/server/worker/macos_setup_assistant.go index cf206c9ff5..3850adc641 100644 --- a/server/worker/macos_setup_assistant.go +++ b/server/worker/macos_setup_assistant.go @@ -384,7 +384,7 @@ func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog. return ctxerr.Wrap(ctx, err, "getting cooldowns") } if len(serialsByTeamId) == 0 { - logger.Log("msg", "no cooldowns to process") + level.Info(logger).Log("msg", "no cooldowns to process") return nil } @@ -394,7 +394,7 @@ func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog. logger.Log("msg", "no cooldowns", "team_id", teamID) continue } - logger.Log("msg", "processing cooldowns", "team_id", teamID, "serials", serials) + level.Info(logger).Log("msg", "processing cooldowns", "team_id", teamID, "serials", serials) var tid *uint if teamID != 0 { From 5bc4acf9e8f3a0a2535b8988c4b4558a29c7c609 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 25 Apr 2024 13:26:26 +0100 Subject: [PATCH 17/83] new dynamic and manual labels UI test and update react testing library (#18369) tests for the new label pages also updates react testing library and its plugins versions - [x] Added/updated tests --- .../TargetsInput/TargetsInput.tests.tsx | 93 ++++++ .../EditLabelPage/EditLabelPage.tests.tsx | 75 +++++ .../DynamicLabelForm.tests.tsx | 45 +++ .../components/LabelForm/LabelForm.tests.tsx | 57 ++++ .../ManualLabelForm/ManualLabelForm.tests.tsx | 49 +++ .../ManualLabelForm/ManualLabelForm.tsx | 2 +- frontend/test/handlers/label-handlers.ts | 15 + frontend/test/test-utils.tsx | 9 - package.json | 7 +- yarn.lock | 278 +++++++++--------- 10 files changed, 479 insertions(+), 151 deletions(-) create mode 100644 frontend/components/LiveQuery/TargetsInput/TargetsInput.tests.tsx create mode 100644 frontend/pages/labels/EditLabelPage/EditLabelPage.tests.tsx create mode 100644 frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tests.tsx create mode 100644 frontend/pages/labels/components/LabelForm/LabelForm.tests.tsx create mode 100644 frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tests.tsx create mode 100644 frontend/test/handlers/label-handlers.ts diff --git a/frontend/components/LiveQuery/TargetsInput/TargetsInput.tests.tsx b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tests.tsx new file mode 100644 index 0000000000..9f3ac57a5e --- /dev/null +++ b/frontend/components/LiveQuery/TargetsInput/TargetsInput.tests.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { noop } from "lodash"; +import { render, screen } from "@testing-library/react"; + +import createMockHost from "__mocks__/hostMock"; +import { IHost } from "interfaces/host"; + +import TargetsInput from "./TargetsInput"; +import { ITargestInputHostTableConfig } from "./TargetsInputHostsTableConfig"; + +describe("TargetsInput", () => { + it("renders the search table based on the custom configuration passed in", () => { + const testHosts: IHost[] = [ + createMockHost({ + display_name: "testHost", + public_ip: "123.456.789.0", + computer_name: "testName", + }), + ]; + + const testTableConfig: ITargestInputHostTableConfig[] = [ + { + Header: "Name", + accessor: "display_name", + }, + { + Header: "IP Address", + accessor: "public_ip", + }, + ]; + + render( + + ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("IP Address")).toBeInTheDocument(); + expect(screen.getByText("testHost")).toBeInTheDocument(); + expect(screen.getByText("123.456.789.0")).toBeInTheDocument(); + expect(screen.queryByText("testName")).not.toBeInTheDocument(); + }); + + it("renders the results table based on the custom configuration passed in", () => { + const testHosts: IHost[] = [ + createMockHost({ + display_name: "testHost", + public_ip: "123.456.789.0", + computer_name: "testName", + }), + ]; + + const testTableConfig: ITargestInputHostTableConfig[] = [ + { + Header: "Name", + accessor: "display_name", + }, + { + Header: "IP Address", + accessor: "public_ip", + }, + ]; + + render( + + ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("IP Address")).toBeInTheDocument(); + expect(screen.getByText("testHost")).toBeInTheDocument(); + expect(screen.getByText("123.456.789.0")).toBeInTheDocument(); + expect(screen.queryByText("testName")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/labels/EditLabelPage/EditLabelPage.tests.tsx b/frontend/pages/labels/EditLabelPage/EditLabelPage.tests.tsx new file mode 100644 index 0000000000..15a4e53e08 --- /dev/null +++ b/frontend/pages/labels/EditLabelPage/EditLabelPage.tests.tsx @@ -0,0 +1,75 @@ +import React from "react"; + +import { screen } from "@testing-library/react"; +import { createCustomRenderer } from "test/test-utils"; +import mockServer from "test/mock-server"; +import { getLabelHandler } from "test/handlers/label-handlers"; + +import EditLabelPage from "./EditLabelPage"; + +// TODO: make this a utility for other tests. +const generateMockRouterProps = (overrides?: any) => { + return { + location: {}, + params: {}, + route: {}, + router: [], + routeParams: {}, + ...overrides, + }; +}; + +describe("EditLabelPage", () => { + it("renders a message for build in labels", async () => { + mockServer.use(getLabelHandler({ label_type: "builtin" })); + const render = createCustomRenderer({ withBackendMock: true }); + + const routerProps = generateMockRouterProps({ + routeParams: { label_id: "1" }, + }); + render(); + + // waiting for the message to render + const builtinMessage = await screen.findByText( + "Built in labels cannot be edited" + ); + + expect(builtinMessage).toBeInTheDocument(); + }); + + it("renders the DynamicLabelForm when the label is dynamic", async () => { + mockServer.use(getLabelHandler({ label_membership_type: "dynamic" })); + const render = createCustomRenderer({ withBackendMock: true }); + + const routerProps = generateMockRouterProps({ + routeParams: { label_id: "1" }, + }); + render(); + + // waiting for the message to render + const queryLabel = await screen.findByText("Query"); + const platformLabel = await screen.findByText("Platform"); + + expect(queryLabel).toBeInTheDocument(); + expect(platformLabel).toBeInTheDocument(); + expect(screen.getByText(/Label queries are immutable/)).toBeInTheDocument(); + expect( + screen.getByText(/Label platforms are immutable/) + ).toBeInTheDocument(); + }); + + it("renders the ManualLabelForm when the label is manual", async () => { + mockServer.use(getLabelHandler({ label_membership_type: "manual" })); + const render = createCustomRenderer({ withBackendMock: true }); + + const routerProps = generateMockRouterProps({ + routeParams: { label_id: "1" }, + }); + render(); + + // waiting for the message to render + const selectHostsLabel = await screen.findByText("Select hosts"); + + expect(selectHostsLabel).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tests.tsx b/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tests.tsx new file mode 100644 index 0000000000..13cee290fa --- /dev/null +++ b/frontend/pages/labels/components/DynamicLabelForm/DynamicLabelForm.tests.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { noop } from "lodash"; +import { render, screen } from "@testing-library/react"; + +import { renderWithSetup } from "test/test-utils"; + +import DynamicLabelForm from "./DynamicLabelForm"; + +describe("DynamicLabelForm", () => { + it("should render the Fleet Ace and Select Platform input", () => { + render(); + + expect(screen.getByText("Query")).toBeInTheDocument(); + expect(screen.getByText("All platforms")).toBeInTheDocument(); + }); + + it("should pass up the form data when the form is submitted and valid", async () => { + const onSave = jest.fn(); + + const name = "Test Name"; + const description = "Test Description"; + const query = "SELECT * FROM users;"; + const platform = "darwin"; + + const { user } = renderWithSetup( + + ); + + await user.type(screen.getByLabelText("Name"), name); + await user.type(screen.getByLabelText("Description"), description); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith({ + name, + description, + query, + platform, + }); + }); +}); diff --git a/frontend/pages/labels/components/LabelForm/LabelForm.tests.tsx b/frontend/pages/labels/components/LabelForm/LabelForm.tests.tsx new file mode 100644 index 0000000000..719e8ee69c --- /dev/null +++ b/frontend/pages/labels/components/LabelForm/LabelForm.tests.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { renderWithSetup } from "test/test-utils"; +import { screen, render } from "@testing-library/react"; +import { noop } from "lodash"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; + +import LabelForm from "./LabelForm"; + +describe("LabelForm", () => { + it("should validate the name to be required", async () => { + const { user } = renderWithSetup( + + ); + + const nameInput = screen.getByLabelText("Name"); + + await user.click(screen.getByRole("button", { name: "Save" })); + expect(screen.getByText("Label name must be present")).toBeInTheDocument(); + + await user.type(nameInput, "Label name"); + expect( + screen.queryByText("Label name must be present") + ).not.toBeInTheDocument(); + }); + + it("should render any additional field the user provides", () => { + render( + } + /> + ); + + expect(screen.getByLabelText("test field")).toBeInTheDocument(); + }); + + it("should pass up the form data when the form is submitted and valid", async () => { + const onSave = jest.fn(); + const { user } = renderWithSetup( + + ); + + const nameValue = "Test Name"; + const descriptionValue = "Test Description"; + await user.type(screen.getByLabelText("Name"), nameValue); + await user.type(screen.getByLabelText("Description"), descriptionValue); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith( + { name: nameValue, description: descriptionValue }, + true + ); + }); +}); diff --git a/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tests.tsx b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tests.tsx new file mode 100644 index 0000000000..04b3a84ee3 --- /dev/null +++ b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tests.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { noop } from "lodash"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; +import createMockHost from "__mocks__/hostMock"; + +import ManualLabelForm, { + LABEL_TARGET_HOSTS_INPUT_LABEL, +} from "./ManualLabelForm"; + +describe("ManualLabelForm", () => { + it("should render a Select Hosts input", () => { + const render = createCustomRenderer({ withBackendMock: true }); + + render(); + + expect( + screen.getByText(LABEL_TARGET_HOSTS_INPUT_LABEL) + ).toBeInTheDocument(); + }); + + it("should pass up the form data when the form is submitted and valid", async () => { + const render = createCustomRenderer({ withBackendMock: true }); + const onSave = jest.fn(); + + const name = "Test Name"; + const description = "Test Description"; + const targetedHosts = [createMockHost()]; + + const { user } = render( + + ); + + await user.type(screen.getByLabelText("Name"), name); + await user.type(screen.getByLabelText("Description"), description); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith({ + name, + description, + targetedHosts, + }); + }); +}); diff --git a/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx index 77b243a337..c4f52af7b6 100644 --- a/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx +++ b/frontend/pages/labels/components/ManualLabelForm/ManualLabelForm.tsx @@ -14,7 +14,7 @@ import { generateTableHeaders } from "./LabelHostTargetTableConfig"; const baseClass = "ManualLabelForm"; -const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts"; +export const LABEL_TARGET_HOSTS_INPUT_LABEL = "Select hosts"; const LABEL_TARGET_HOSTS_INPUT_PLACEHOLDER = "Search name, hostname, or serial number"; const DEBOUNCE_DELAY = 500; diff --git a/frontend/test/handlers/label-handlers.ts b/frontend/test/handlers/label-handlers.ts new file mode 100644 index 0000000000..da1c8959c2 --- /dev/null +++ b/frontend/test/handlers/label-handlers.ts @@ -0,0 +1,15 @@ +import { rest } from "msw"; + +import { baseUrl } from "test/test-utils"; +import { createMockLabel } from "__mocks__/labelsMock"; +import { ILabel } from "interfaces/label"; + +// eslint-disable-next-line import/prefer-default-export +export const getLabelHandler = (overrides: Partial) => + rest.get(baseUrl("/labels/:id"), (req, res, context) => { + return res( + context.json({ + label: createMockLabel({ ...overrides }), + }) + ); + }); diff --git a/frontend/test/test-utils.tsx b/frontend/test/test-utils.tsx index 878b08d8ea..4111300afc 100644 --- a/frontend/test/test-utils.tsx +++ b/frontend/test/test-utils.tsx @@ -47,15 +47,6 @@ interface ICustomRenderOptions { withBackendMock?: boolean; } -// TODO: types -// type RenderOptionsWithoutUserEvents = ICustomRenderOptions & { -// withUserEvents: false; -// }; - -// type RenderOptionsWithUserEvents = ICustomRenderOptions & { -// withUserEvents: true; -// }; - const CONTEXT_PROVIDER_MAP = { app: AppContext, notification: NotificationContext, diff --git a/package.json b/package.json index f9e4ec1145..01622139c3 100644 --- a/package.json +++ b/package.json @@ -91,14 +91,15 @@ "@storybook/react": "7.5.2", "@storybook/react-webpack5": "7.5.2", "@storybook/test-runner": "0.13.0", - "@testing-library/jest-dom": "5.16.2", - "@testing-library/react": "12.1.4", - "@testing-library/user-event": "14.4.3", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "15.0.2", + "@testing-library/user-event": "14.5.2", "@tsconfig/recommended": "1.0.1", "@types/chrome": "0.0.237", "@types/classnames": "0.0.32", "@types/expect": "1.20.3", "@types/file-saver": "2.0.5", + "@types/jest": "29.5.12", "@types/js-md5": "0.4.3", "@types/js-yaml": "4.0.5", "@types/lodash": "4.14.179", diff --git a/yarn.lock b/yarn.lock index e585bc7203..4932b7c84a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.3.2": + version "4.3.3" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" + integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -2290,7 +2295,7 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== @@ -2311,6 +2316,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.9.2": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.16.7", "@babel/template@^7.18.10", "@babel/template@^7.3.3": version "7.18.10" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" @@ -2890,6 +2902,13 @@ dependencies: jest-get-type "^29.2.0" +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + "@jest/expect@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-28.1.3.tgz#9ac57e1d4491baca550f6bdbd232487177ad6a72" @@ -4696,48 +4715,47 @@ resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a" integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw== -"@testing-library/dom@^8.0.0": - version "8.11.3" - resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.3.tgz" - integrity sha512-9LId28I+lx70wUiZjLvi1DB/WT2zGOxUh46glrSNMaWVx849kKAluezVzZrXJfTKKoQTmEOutLes/bHg4Bj3aA== +"@testing-library/dom@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.0.0.tgz#ae1ab88aad35a728a38264041163174cafd7e8dd" + integrity sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" - "@types/aria-query" "^4.2.0" - aria-query "^5.0.0" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" chalk "^4.1.0" dom-accessibility-api "^0.5.9" - lz-string "^1.4.4" + lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/jest-dom@5.16.2": - version "5.16.2" - resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.2.tgz#f329b36b44aa6149cd6ced9adf567f8b6aa1c959" - integrity sha512-6ewxs1MXWwsBFZXIk4nKKskWANelkdUehchEOokHsN8X7c2eKXGw+77aRV63UU8f/DTSVUPLaGxdrj4lN7D/ug== +"@testing-library/jest-dom@6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz#38949f6b63722900e2d75ba3c6d9bf8cffb3300e" + integrity sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw== dependencies: + "@adobe/css-tools" "^4.3.2" "@babel/runtime" "^7.9.2" - "@types/testing-library__jest-dom" "^5.9.1" aria-query "^5.0.0" chalk "^3.0.0" - css "^3.0.0" css.escape "^1.5.1" - dom-accessibility-api "^0.5.6" + dom-accessibility-api "^0.6.3" lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@12.1.4": - version "12.1.4" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" - integrity sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA== +"@testing-library/react@15.0.2": + version "15.0.2" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-15.0.2.tgz#d0fd7e9c41b819557639acf5f18e4cd1007ec295" + integrity sha512-5mzIpuytB1ctpyywvyaY2TAAUQVCZIGqwiqFQf6u9lvj/SJQepGUzNV18Xpk+NLCaCE2j7CWrZE0tEf9xLZYiQ== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^8.0.0" - "@types/react-dom" "*" + "@testing-library/dom" "^10.0.0" + "@types/react-dom" "^18.0.0" -"@testing-library/user-event@14.4.3": - version "14.4.3" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" - integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== +"@testing-library/user-event@14.5.2": + version "14.5.2" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.2.tgz#db7257d727c891905947bd1c1a99da20e03c2ebd" + integrity sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ== "@tootallnate/once@1": version "1.1.2" @@ -4774,10 +4792,10 @@ resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.1.tgz#7619bad397e06ead1c5182926c944e0ca6177f52" integrity sha512-2xN+iGTbPBEzGSnVp/Hd64vKJCJWxsi9gfs88x4PPMyEjHJoA3o5BY9r5OLPHIZU2pAQxkSAsJFqn6itClP8mQ== -"@types/aria-query@^4.2.0": - version "4.2.2" - resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz" - integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig== +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== "@types/babel__core@^7.0.0": version "7.20.0" @@ -5031,13 +5049,13 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@*": - version "27.4.1" - resolved "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz" - integrity sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw== +"@types/jest@29.5.12": + version "29.5.12" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.12.tgz#7f7dc6eb4cf246d2474ed78744b05d06ce025544" + integrity sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw== dependencies: - jest-matcher-utils "^27.0.0" - pretty-format "^27.0.0" + expect "^29.0.0" + pretty-format "^29.0.0" "@types/js-levenshtein@^1.1.1": version "1.1.1" @@ -5202,13 +5220,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@*": - version "18.2.19" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" - integrity sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA== - dependencies: - "@types/react" "*" - "@types/react-dom@18.2.0": version "18.2.0" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.0.tgz#374f28074bb117f56f58c4f3f71753bebb545156" @@ -5216,6 +5227,13 @@ dependencies: "@types/react" "*" +"@types/react-dom@^18.0.0": + version "18.2.25" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.25.tgz#2946a30081f53e7c8d585eb138277245caedc521" + integrity sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA== + dependencies: + "@types/react" "*" + "@types/react-router@3.0.28": version "3.0.28" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-3.0.28.tgz#ae457228e6934f6664e6b092afd7f8e1bb6eee3a" @@ -5325,13 +5343,6 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== -"@types/testing-library__jest-dom@^5.9.1": - version "5.14.3" - resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz" - integrity sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw== - dependencies: - "@types/jest" "*" - "@types/tough-cookie@*": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" @@ -6044,7 +6055,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: ansi-styles@^5.0.0: version "5.2.0" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== ansi-styles@^6.1.0: @@ -6117,6 +6128,13 @@ aria-hidden@^1.1.1: dependencies: tslib "^2.0.0" +aria-query@5.3.0, aria-query@^5.0.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + aria-query@^0.7.0: version "0.7.1" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-0.7.1.tgz#26cbb5aff64144b0a825be1846e0b16cfa00b11e" @@ -6125,11 +6143,6 @@ aria-query@^0.7.0: ast-types-flow "0.0.7" commander "^2.11.0" -aria-query@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz" - integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== - array-find@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/array-find/-/array-find-1.0.0.tgz" @@ -6263,11 +6276,6 @@ at-least-node@^1.0.0: resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -atob@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - autoprefixer@10.4.19: version "10.4.19" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" @@ -7045,7 +7053,7 @@ chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.2: chalk@^3.0.0: version "3.0.0" - resolved "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== dependencies: ansi-styles "^4.1.0" @@ -7650,17 +7658,8 @@ css-what@^5.1.0: css.escape@^1.5.1: version "1.5.1" - resolved "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz" - integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= - -css@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/css/-/css-3.0.0.tgz" - integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== - dependencies: - inherits "^2.0.4" - source-map "^0.6.1" - source-map-resolve "^0.6.0" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== cssesc@^3.0.0: version "3.0.0" @@ -7776,11 +7775,6 @@ decode-named-character-reference@^1.0.0: dependencies: character-entities "^2.0.0" -decode-uri-component@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" - integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== - dedent@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz" @@ -7882,7 +7876,7 @@ depd@^1.1.2: resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= -dequal@^2.0.0, dequal@^2.0.2: +dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -7945,11 +7939,6 @@ diff-match-patch@^1.0.4: resolved "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== -diff-sequences@^27.5.1: - version "27.5.1" - resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz" - integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== - diff-sequences@^28.1.1: version "28.1.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" @@ -7960,6 +7949,11 @@ diff-sequences@^29.2.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.2.0.tgz#4c55b5b40706c7b5d2c5c75999a50c56d214e8f6" integrity sha512-413SY5JpYeSBZxmenGEmCVQ8mCgtFJF0w9PROdaS6z987XC2Pd2GOKqOITLtMftmyFZqgtCOb/QA7/Z3ZXfzIw== +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + diff@^3.2.0: version "3.5.0" resolved "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz" @@ -8012,11 +8006,16 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.9: version "0.5.13" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz" integrity sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw== +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz" @@ -9027,6 +9026,17 @@ expect@^28.1.3: jest-message-util "^28.1.3" jest-util "^28.1.3" +expect@^29.0.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + expect@^29.2.0: version "29.2.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.2.0.tgz#b90c6df52be7abfd9f206f273fbcf8b33d8f332d" @@ -10253,7 +10263,7 @@ imurmurhash@^0.1.4: indent-string@^4.0.0: version "4.0.0" - resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== infer-owner@^1.0.4: @@ -10998,16 +11008,6 @@ jest-config@^29.2.0: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^27.5.1: - version "27.5.1" - resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz" - integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== - dependencies: - chalk "^4.0.0" - diff-sequences "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - jest-diff@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-28.1.3.tgz#948a192d86f4e7a64c5264ad4da4877133d8792f" @@ -11028,6 +11028,16 @@ jest-diff@^29.2.0: jest-get-type "^29.2.0" pretty-format "^29.2.0" +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-docblock@^28.1.1: version "28.1.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-28.1.1.tgz#6f515c3bf841516d82ecd57a62eed9204c2f42a8" @@ -11112,11 +11122,6 @@ jest-environment-node@^29.2.0: jest-mock "^29.2.0" jest-util "^29.2.0" -jest-get-type@^27.5.1: - version "27.5.1" - resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz" - integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== - jest-get-type@^28.0.2: version "28.0.2" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" @@ -11127,6 +11132,11 @@ jest-get-type@^29.2.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.2.0.tgz#726646f927ef61d583a3b3adb1ab13f3a5036408" integrity sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA== +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + jest-haste-map@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-28.1.3.tgz#abd5451129a38d9841049644f34b034308944e2b" @@ -11210,16 +11220,6 @@ jest-leak-detector@^29.2.0: jest-get-type "^29.2.0" pretty-format "^29.2.0" -jest-matcher-utils@^27.0.0: - version "27.5.1" - resolved "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz" - integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== - dependencies: - chalk "^4.0.0" - jest-diff "^27.5.1" - jest-get-type "^27.5.1" - pretty-format "^27.5.1" - jest-matcher-utils@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz#5a77f1c129dd5ba3b4d7fc20728806c78893146e" @@ -11240,6 +11240,16 @@ jest-matcher-utils@^29.2.0: jest-get-type "^29.2.0" pretty-format "^29.2.0" +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + jest-message-util@^25.5.0: version "25.5.0" resolved "https://registry.npmjs.org/jest-message-util/-/jest-message-util-25.5.0.tgz" @@ -12238,10 +12248,10 @@ lru-cache@^7.7.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a" integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== -lz-string@^1.4.4: - version "1.4.4" - resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz" - integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" @@ -12921,7 +12931,7 @@ mimic-fn@^2.1.0: min-indent@^1.0.0: version "1.0.1" - resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== mini-css-extract-plugin@2.7.5: @@ -14074,7 +14084,7 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" -pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1: +pretty-format@^27.0.2: version "27.5.1" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz" integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== @@ -14093,6 +14103,15 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-format@^29.2.0: version "29.2.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.2.0.tgz#1d4ea56fb46079b44efd9ed59c14f70f2950a61b" @@ -14102,15 +14121,6 @@ pretty-format@^29.2.0: ansi-styles "^5.0.0" react-is "^18.0.0" -pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz" @@ -14474,7 +14484,7 @@ react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0: react-is@^17.0.1: version "17.0.2" - resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== react-is@^18.0.0: @@ -14764,7 +14774,7 @@ rechoir@^0.8.0: redent@^3.0.0: version "3.0.0" - resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== dependencies: indent-string "^4.0.0" @@ -14801,7 +14811,7 @@ regenerate@^1.4.2: resolved "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -regenerator-runtime@0.13.9, regenerator-runtime@^0.13.4: +regenerator-runtime@0.13.9: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== @@ -14811,7 +14821,7 @@ regenerator-runtime@^0.11.0: resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.11: +regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.4: version "0.13.11" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== @@ -15501,14 +15511,6 @@ source-map-js@^1.2.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== -source-map-resolve@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz" - integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== - dependencies: - atob "^2.1.2" - decode-uri-component "^0.2.0" - source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -15835,7 +15837,7 @@ strip-final-newline@^2.0.0: strip-indent@^3.0.0: version "3.0.0" - resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== dependencies: min-indent "^1.0.0" From b82d5f4ef9d49fd1b57fb40219b9bfb3bae60ab0 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Thu, 25 Apr 2024 09:05:18 -0500 Subject: [PATCH 18/83] Add MDM commands directory to /it-and-security (#18535) --- .../mdm-commands/apple/send-fleetd.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 it-and-security/mdm-commands/apple/send-fleetd.xml diff --git a/it-and-security/mdm-commands/apple/send-fleetd.xml b/it-and-security/mdm-commands/apple/send-fleetd.xml new file mode 100644 index 0000000000..0a10ff6d99 --- /dev/null +++ b/it-and-security/mdm-commands/apple/send-fleetd.xml @@ -0,0 +1,15 @@ + + + + Command + + ManifestURL + https://download.fleetdm.com/fleetd-base-manifest.plist + RequestType + InstallEnterpriseApplication + + + CommandUUID + adc1bc23-abec-4499-b57f-c8755c7ffe3c + + From 3a538e9729499c9ce14963ba4ad080de19b8eb79 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Thu, 25 Apr 2024 09:56:50 -0500 Subject: [PATCH 19/83] Update policies via GitOps (#18537) --- .../lib/macos-device-health.policies.yml | 9 --------- it-and-security/teams/workstations-canary.yml | 9 +++++++++ it-and-security/teams/workstations.yml | 13 +++++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/it-and-security/lib/macos-device-health.policies.yml b/it-and-security/lib/macos-device-health.policies.yml index 427ac7a149..f06480bd50 100644 --- a/it-and-security/lib/macos-device-health.policies.yml +++ b/it-and-security/lib/macos-device-health.policies.yml @@ -64,12 +64,3 @@ 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 -- name: macOS - Check if latest version - query: | - SELECT 1 FROM os_version - WHERE (major > 14 OR (major = 14 AND minor > 4) OR (major = 14 AND minor = 4 AND patch >= 1)) --Sonoma - critical: false - description: This policy check if macOS version is most recent version available. - resolution: From the Apple menu, select System Settings. Navigate to General > Software Update. - platform: darwin - calendar_events_enabled: true diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index 4f6c62a93a..5d9334c09c 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -109,6 +109,15 @@ policies: - path: ../lib/macos-device-health.policies.yml - 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 OR (major = 14 AND minor > 4) OR (major = 14 AND minor = 4 AND patch >= 2)) --Sonoma + critical: false + description: This policy check if macOS version is most recent version available. + resolution: From the Apple menu, select System Settings. Navigate to General > Software Update. + platform: darwin + calendar_events_enabled: true queries: - path: ../lib/collect-failed-login-attempts.queries.yml - path: ../lib/collect-fleetd-information.yml diff --git a/it-and-security/teams/workstations.yml b/it-and-security/teams/workstations.yml index 95ca009dc3..491eab9211 100644 --- a/it-and-security/teams/workstations.yml +++ b/it-and-security/teams/workstations.yml @@ -8,6 +8,10 @@ team_settings: host_expiry_window: 0 secrets: - secret: $DOGFOOD_WORKSTATIONS_ENROLL_SECRET + integrations: + google_calendar: + enable_calendar_events: true + webhook_url: $DOGFOOD_WORKSTATIONS_CANARY_CALENDAR_WEBHOOK_URL agent_options: path: ../lib/agent-options.yml controls: @@ -57,6 +61,15 @@ policies: - path: ../lib/macos-device-health.policies.yml - 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 OR (major = 14 AND minor > 4) OR (major = 14 AND minor = 4 AND patch >= 1)) --Sonoma + critical: false + description: This policy check if macOS version is most recent version available. + resolution: From the Apple menu, select System Settings. Navigate to General > Software Update. + platform: darwin + calendar_events_enabled: true queries: - path: ../lib/collect-failed-login-attempts.queries.yml - path: ../lib/collect-usb-devices.queries.yml From 850e429b804d61c7570ef94cb85c70ad2b3475d7 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 25 Apr 2024 10:19:16 -0500 Subject: [PATCH 20/83] Better error handling in "Get human interpretation from osquery sql" (#18529) + Use the same "Failed to generate human interpretation using generative AI." message if you send it something weird like "asdf" (so you don't get a vague 500 error). + Simplify the code a bit --------- Co-authored-by: Eric --- ...t-human-interpretation-from-osquery-sql.js | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/website/api/controllers/get-human-interpretation-from-osquery-sql.js b/website/api/controllers/get-human-interpretation-from-osquery-sql.js index e31485c5dc..a7d2e18b4a 100644 --- a/website/api/controllers/get-human-interpretation-from-osquery-sql.js +++ b/website/api/controllers/get-human-interpretation-from-osquery-sql.js @@ -49,19 +49,19 @@ Remember to minimize the number of words used! Please give me all of the above in JSON, with this data shape: -\`\`\` { risks: 'TODO', whatWillProbablyHappenDuringMaintenance: 'TODO' } -\`\`\``; + +Please do not add any text outside of the JSON report or wrap it in a code fence.`; // Fallback message in case LLM API request fails. let failureMessage = 'Failed to generate human interpretation using generative AI.'; let BASE_MODEL = 'gpt-4';// The base model to use. https://platform.openai.com/docs/models/gpt-4 // (Max tokens for gpt-3.5 ≈≈ 4000) (Max tokens for gpt-4 ≈≈ 8000) // [?] API: https://platform.openai.com/docs/api-reference/chat/create - let llmReport = await sails.helpers.http.post('https://api.openai.com/v1/chat/completions', { + let openAiResponse = await sails.helpers.http.post('https://api.openai.com/v1/chat/completions', { model: BASE_MODEL, messages: [// https://platform.openai.com/docs/guides/chat/introduction { @@ -76,21 +76,26 @@ Please give me all of the above in JSON, with this data shape: }) .tolerate((err)=>{ sails.log.warn(failureMessage+' Error details from LLM: '+err.stack); - return; + return { + choices: [ + { + message: { + content: `{ "risks": "${failureMessage}", "whatWillProbablyHappenDuringMaintenance": "${failureMessage}" }` + } + } + ] + }; }); - // Get data into expected formaat let report; - if (!llmReport) {// If LLM could not be reached… - // FUTURE: Actually negotiate errors instead of just pretending it works but sending back garbage. + try { + report = JSON.parse(openAiResponse.choices[0].message.content); + } catch (err) { + sails.log.warn('When trying to parse a JSON report returned from the Open AI API, an error occurred. Error details from JSON.parse: '+err.stack+'\n Report returned from Open AI:'+openAiResponse.choices[0].message.content); report = { risks: failureMessage, whatWillProbablyHappenDuringMaintenance: failureMessage }; - } else {// Otherwise, descriptions were successfully generated… - let llmMessage = llmReport.choices[0].message.content; - llmMessage = llmMessage.replace(/\`\`\`/g, ''); - report = JSON.parse(llmMessage); } return report; From 0ccc7f62443754d9393e3df39f1686207a28a109 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 25 Apr 2024 10:32:49 -0500 Subject: [PATCH 21/83] Use "Fleet's agent (fleetd)" naming in the CLI (#18528) For https://github.com/fleetdm/fleet/issues/16512 --- cmd/fleetctl/package.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/fleetctl/package.go b/cmd/fleetctl/package.go index 15d68b6134..531c734507 100644 --- a/cmd/fleetctl/package.go +++ b/cmd/fleetctl/package.go @@ -30,7 +30,7 @@ func packageCommand() *cli.Command { return &cli.Command{ Name: "package", Aliases: nil, - Usage: "Create an Orbit installer package", + Usage: "Create a fleetd package", Description: "An easy way to create fully boot-strapped installer packages for Windows, macOS, or Linux", Flags: []cli.Flag{ &cli.StringFlag{ From 2c9978ef21ecb62cd8a757a152aa52b389998696 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 25 Apr 2024 10:33:29 -0500 Subject: [PATCH 22/83] Use "Fleet's agent (fleetd)" naming in the UI (#18238) For https://github.com/fleetdm/fleet/issues/16512 --------- Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> --- .../EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx | 4 +--- frontend/pages/DashboardPage/cards/MDM/MDM.tsx | 4 ++-- frontend/pages/DashboardPage/cards/Munki/Munki.tsx | 4 ++-- frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx | 2 +- .../TeamDetailsWrapper/AgentOptionsPage/AgentOptionsPage.tsx | 2 +- frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx | 2 +- frontend/pages/policies/constants.ts | 2 +- 7 files changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx b/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx index 6238373843..1c32637a17 100644 --- a/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx +++ b/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx @@ -50,9 +50,7 @@ const DeleteSecretModal = ({ unable to enroll.

- Any enrolled hosts using this secret will not receive updates - through Orbit including updates to agent options and command line - flags. + Hosts that enrolled with this secret will not get updates to agent options.

Follow this guide to{" "} diff --git a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx index 2e79bd8749..583460c128 100644 --- a/frontend/pages/DashboardPage/cards/MDM/MDM.tsx +++ b/frontend/pages/DashboardPage/cards/MDM/MDM.tsx @@ -57,9 +57,9 @@ const EmptyMdmStatus = (): JSX.Element => ( <> To see MDM versions, deploy  } diff --git a/frontend/pages/DashboardPage/cards/Munki/Munki.tsx b/frontend/pages/DashboardPage/cards/Munki/Munki.tsx index ea77e59a2a..8b12f2d2a1 100644 --- a/frontend/pages/DashboardPage/cards/Munki/Munki.tsx +++ b/frontend/pages/DashboardPage/cards/Munki/Munki.tsx @@ -109,8 +109,8 @@ const Munki = ({ <> To see Munki versions, deploy  . diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx index c0acbfc680..1a57ef5576 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx @@ -81,7 +81,7 @@ const Agents = ({

- Agent options configure the osquery agent. When you update agent + Agent options configure Fleet's agent (fleetd). When you update agent options, they will be applied the next time a host checks in to Fleet.{" "}

- Agent options configure the osquery agent. When you update agent + Agent options configure Fleet's agent (fleetd). When you update agent options, they will be applied the next time a host checks in to Fleet.
Add hosts diff --git a/frontend/pages/policies/constants.ts b/frontend/pages/policies/constants.ts index 6bc9ded8a3..a4284615a2 100644 --- a/frontend/pages/policies/constants.ts +++ b/frontend/pages/policies/constants.ts @@ -126,7 +126,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [ query: "SELECT 1 FROM mdm WHERE enrolled='true';", name: "MDM enrolled (macOS)", 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.", + "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", critical: false, platform: "darwin", From fb767c054107b1a49453e7560bf0eaeb1a3c5682 Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Thu, 25 Apr 2024 11:41:51 -0400 Subject: [PATCH 23/83] Update falconctl_options.yml (#18536) Fixed typo in "falconctl" string. --- schema/tables/falconctl_options.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schema/tables/falconctl_options.yml b/schema/tables/falconctl_options.yml index 9e839c82eb..6be656af4b 100644 --- a/schema/tables/falconctl_options.yml +++ b/schema/tables/falconctl_options.yml @@ -6,6 +6,6 @@ platforms: - linux columns: - name: options - description: "The falconctol options to run. Supported values are listed here: `--aid`, `--apd`,`--aph`, `--app`, `--cid`, `--feature`, `--metadata-query`, `--rfm-reason`,`--rfm-state`, `--tags`, `--version`" + description: "The falconctl options to run. Supported values are listed here: `--aid`, `--apd`,`--aph`, `--app`, `--cid`, `--feature`, `--metadata-query`, `--rfm-reason`,`--rfm-state`, `--tags`, `--version`" type: text - required: true \ No newline at end of file + required: true From a44bde8eee4a2f5469eca8481a9d4007405d716f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:43:01 -0500 Subject: [PATCH 24/83] Update versions of fleetd components in Fleet's TUF [automated] (#18488) --- orbit/TUF.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orbit/TUF.md b/orbit/TUF.md index ff9a89953f..64d8bb4a1b 100644 --- a/orbit/TUF.md +++ b/orbit/TUF.md @@ -7,8 +7,8 @@ Following are the currently deployed versions of fleetd components on the `stabl | Component\OS | macOS | Linux | Windows | |--------------|--------------|--------|---------| -| orbit | 1.23.0 | 1.23.0 | 1.23.0 | -| desktop | 1.23.0 | 1.23.0 | 1.23.0 | +| orbit | 1.24.0 | 1.24.0 | 1.24.0 | +| desktop | 1.24.0 | 1.24.0 | 1.24.0 | | osqueryd | 5.12.1 | 5.12.1 | 5.12.1 | | nudge | 1.1.10.81462 | - | - | | swiftDialog | 2.1.0 | - | - | From c423f415a6d3ce1867419eed2acf30ee97e57185 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Thu, 25 Apr 2024 17:09:53 +0100 Subject: [PATCH 25/83] Fix uploading signed apple mobileconfig profiles (#18482) relates to #18389 This fixes an issue with some signed apple mobileconfig profiles were not able to be uploaded. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality --- ...loading-signed-apple-mobileconfig-profiles | 1 + go.mod | 16 ++-- go.sum | 80 +++++-------------- server/mdm/apple/mobileconfig/mobileconfig.go | 43 ++++++---- 4 files changed, 57 insertions(+), 83 deletions(-) create mode 100644 changes/issue-18389-fix-uploading-signed-apple-mobileconfig-profiles diff --git a/changes/issue-18389-fix-uploading-signed-apple-mobileconfig-profiles b/changes/issue-18389-fix-uploading-signed-apple-mobileconfig-profiles new file mode 100644 index 0000000000..b6b57c9f29 --- /dev/null +++ b/changes/issue-18389-fix-uploading-signed-apple-mobileconfig-profiles @@ -0,0 +1 @@ +- fix issue with uploading of some signed apple mobileconfig profiles diff --git a/go.mod b/go.mod index d741cf7e99..189e6db87f 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/fatih/color v1.15.0 github.com/getsentry/sentry-go v0.18.0 github.com/ghodss/yaml v1.0.0 + github.com/github/smimesign v0.2.0 github.com/go-git/go-git/v5 v5.11.0 github.com/go-ini/ini v1.67.0 github.com/go-kit/kit v0.12.0 @@ -58,14 +59,14 @@ require ( github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95 github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f github.com/igm/sockjs-go/v3 v3.0.2 - github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5 + github.com/jmoiron/sqlx v1.3.5 github.com/josephspurrier/goversioninfo v1.4.0 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 v0.0.15 github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e - github.com/mattn/go-sqlite3 v1.14.13 + github.com/mattn/go-sqlite3 v1.14.22 github.com/micromdm/micromdm v1.9.0 github.com/mitchellh/go-ps v1.0.0 github.com/mitchellh/gon v0.2.6-0.20231031204852-2d4f161ccecd @@ -82,7 +83,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.0 - github.com/prometheus/client_golang v1.13.0 + github.com/prometheus/client_golang v1.19.0 github.com/quasilyte/go-ruleguard/dsl v0.3.22 github.com/rs/zerolog v1.20.0 github.com/russellhaering/goxmldsig v1.2.0 @@ -115,7 +116,7 @@ require ( golang.org/x/image v0.10.0 golang.org/x/mod v0.12.0 golang.org/x/net v0.24.0 - golang.org/x/oauth2 v0.12.0 + golang.org/x/oauth2 v0.16.0 golang.org/x/sync v0.3.0 golang.org/x/sys v0.19.0 golang.org/x/text v0.14.0 @@ -257,7 +258,6 @@ require ( github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mattn/go-tty v0.0.3 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect @@ -269,9 +269,9 @@ require ( github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.37.0 // indirect - github.com/prometheus/procfs v0.8.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.5.0 // indirect diff --git a/go.sum b/go.sum index e044f0d205..0435d2498e 100644 --- a/go.sum +++ b/go.sum @@ -197,10 +197,7 @@ github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/g github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 h1:NjwIgLQlD46o79bheVG4SCdRnnOz4XtgUN1WABX5DLA= github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725/go.mod h1:/n6+1/DWPltRLWL/VKyUxg6tzsl5kHUCcraimt4vr60= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= @@ -305,10 +302,10 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -441,6 +438,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= +github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= @@ -466,16 +465,12 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-kit/kit v0.7.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -493,7 +488,6 @@ github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTM github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= -github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= @@ -754,8 +748,8 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v0.0.0-20180406164412-2aeb6a910c2b/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU= -github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5 h1:lrdPtrORjGv1HbbEvKWDUAy97mPpFm4B8hp77tcCUJY= -github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= @@ -765,17 +759,12 @@ github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUB github.com/josephspurrier/goversioninfo v1.4.0 h1:Puhl12NSHUSALHSuzYwPYQkqa2E1+7SrtAPJorKK0C8= github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kevinburke/go-bindata v3.24.0+incompatible h1:qajFA3D0pH94OTLU4zcCCKCDgR+Zr2cZK/RPJHDdFoY= github.com/kevinburke/go-bindata v3.24.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= @@ -794,7 +783,6 @@ github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab/go.mod h1:OYYulo9tUqRad github.com/kolide/launcher v1.0.12 h1:f2uT1kKYGIbj/WVsHDc10f7MIiwu8MpmgwaGaT7D09k= github.com/kolide/launcher v1.0.12/go.mod h1:j854Q4LqMXi3DQ+fnDy8Ij4uuKRG707ulWOcIz7BCz4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -810,6 +798,7 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -846,16 +835,14 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I= -github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= -github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/micromdm/micromdm v1.9.0 h1:FAsIKOpnGcq21UQCrHCUxZwSW4NwBLGOoUtzbURxds8= github.com/micromdm/micromdm v1.9.0/go.mod h1:YsAtsEvfEIwpjYTUPpWkJXSfH0hhp9mMHW1BgIZgRt8= @@ -895,11 +882,9 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/ngrok/sqlmw v0.0.0-20211220175533-9d16fdc47b31 h1:FFHgfAIoAXCCL4xBoAugZVpekfGmZ/fBBueneUKBv7I= @@ -937,6 +922,7 @@ github.com/pandatix/nvdapi v0.6.4/go.mod h1:DVYxPq0JRERgYzFmwTMknAtH4kB8v9KG+z40 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= @@ -960,34 +946,23 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU= -github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= -github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190425082905-87a4384529e0/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo= -github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= @@ -1035,8 +1010,6 @@ github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnj github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -1317,7 +1290,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1349,15 +1321,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= @@ -1384,9 +1353,8 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= -golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1415,7 +1383,6 @@ golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1437,7 +1404,6 @@ golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1451,8 +1417,6 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1476,7 +1440,6 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1487,8 +1450,6 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211102192858-4dd72447c267/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1797,7 +1758,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/server/mdm/apple/mobileconfig/mobileconfig.go b/server/mdm/apple/mobileconfig/mobileconfig.go index ac7b11f89a..8fecaed417 100644 --- a/server/mdm/apple/mobileconfig/mobileconfig.go +++ b/server/mdm/apple/mobileconfig/mobileconfig.go @@ -8,8 +8,11 @@ import ( "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/mdm" + + // we are using this package as we were having issues with pasrsing signed apple + // mobileconfig profiles with the pcks7 package we were using before. + cms "github.com/github/smimesign/ietf-cms" "github.com/micromdm/micromdm/pkg/crypto/profileutil" - "go.mozilla.org/pkcs7" "howett.net/plist" ) @@ -81,6 +84,24 @@ type Parsed struct { PayloadType string } +func (mc Mobileconfig) isSignedProfile() bool { + return !bytes.HasPrefix(bytes.TrimSpace(mc), []byte(" Date: Thu, 25 Apr 2024 13:10:14 -0300 Subject: [PATCH 26/83] increase timeout for the installation of Colima (#18533) saw many jobs timeout and fail. Optionally, should we use `macos-12` that comes with Colima pre-installed? can make that change as well, just lmk --- .github/workflows/test-packaging.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-packaging.yml b/.github/workflows/test-packaging.yml index 84cb2cb5f8..7190314cb9 100644 --- a/.github/workflows/test-packaging.yml +++ b/.github/workflows/test-packaging.yml @@ -63,7 +63,7 @@ jobs: - name: Run Colima if: startsWith(matrix.os, 'macos') - timeout-minutes: 5 + timeout-minutes: 10 # notes: # - docker to install the docker CLI and interact with the Colima # container runtime From 414c27d53b6b54c4b08b814fa98d91e30dfae1d6 Mon Sep 17 00:00:00 2001 From: Grant Bilstad <82750216+pacamaster@users.noreply.github.com> Date: Thu, 25 Apr 2024 10:19:33 -0600 Subject: [PATCH 27/83] Add link to file carve contributor docs (#18309) File carves were moved to their own section in contributor docs https://github.com/fleetdm/fleet/pull/16877 Fixed link on API page --------- Co-authored-by: Rachael Shaw --- docs/REST API/rest-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 205982e524..a36a0d2325 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -632,7 +632,7 @@ Fleet supports osquery's file carving functionality as of Fleet 3.3.0. This allo To initiate a file carve using the Fleet API, you can use the [live query](#run-live-query) endpoint to run a query against the `carves` table. -For more information on executing a file carve in Fleet, go to the [File carving with Fleet docs](https://fleetdm.com/docs/using-fleet/fleetctl-cli#file-carving-with-fleet). +For more information on executing a file carve in Fleet, go to the [File carving with Fleet docs](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/File-carving.md). ### List carves From 6dabcd524ceb89270af3e463c9c3aa0a24fb368a Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:03:30 -0400 Subject: [PATCH 28/83] Frontend: Improve URL and email validation (#18445) --- changes/17360-better-url-email-validators | 1 + .../RegistrationForm/AdminDetails/helpers.js | 6 ++--- .../validators/valid_email/valid_email.ts | 11 +++------ .../forms/validators/valid_url/valid_url.ts | 23 +++++++------------ .../IntegrationForm/IntegrationForm.tsx | 2 +- .../admin/OrgSettingsPage/cards/Info/Info.tsx | 9 ++++++-- .../admin/OrgSettingsPage/cards/Sso/Sso.tsx | 4 +++- .../cards/WebAddress/WebAddress.tsx | 2 +- .../CalendarEventsModal.tsx | 2 +- package.json | 2 ++ yarn.lock | 19 ++++++++++++++- 11 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 changes/17360-better-url-email-validators diff --git a/changes/17360-better-url-email-validators b/changes/17360-better-url-email-validators new file mode 100644 index 0000000000..079733c24e --- /dev/null +++ b/changes/17360-better-url-email-validators @@ -0,0 +1 @@ +- UI: Improve URL and email validation \ No newline at end of file diff --git a/frontend/components/forms/RegistrationForm/AdminDetails/helpers.js b/frontend/components/forms/RegistrationForm/AdminDetails/helpers.js index 5569a0b986..3fe4e84575 100644 --- a/frontend/components/forms/RegistrationForm/AdminDetails/helpers.js +++ b/frontend/components/forms/RegistrationForm/AdminDetails/helpers.js @@ -12,12 +12,10 @@ const validate = (formData) => { name, } = formData; - if (!validEmail(email)) { - errors.email = "Email must be a valid email"; - } - if (!email) { errors.email = "Email must be present"; + } else if (!validEmail(email)) { + errors.email = "Email must be a valid email"; } if (!name) { diff --git a/frontend/components/forms/validators/valid_email/valid_email.ts b/frontend/components/forms/validators/valid_email/valid_email.ts index 8658bd0529..090a2e1cf8 100644 --- a/frontend/components/forms/validators/valid_email/valid_email.ts +++ b/frontend/components/forms/validators/valid_email/valid_email.ts @@ -1,12 +1,7 @@ -// see https://stackoverflow.com/a/201378 +// https://github.com/validatorjs/validator.js/blob/master/README.md#validators -// eslint-disable-next-line no-control-regex -const EMAIL_REGEX = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; +import isEmail from "validator/lib/isEmail"; export default (email: string): boolean => { - if (EMAIL_REGEX.test(email)) { - return true; - } - - return false; + return isEmail(email); }; diff --git a/frontend/components/forms/validators/valid_url/valid_url.ts b/frontend/components/forms/validators/valid_url/valid_url.ts index adbdf37f78..1707e991f1 100644 --- a/frontend/components/forms/validators/valid_url/valid_url.ts +++ b/frontend/components/forms/validators/valid_url/valid_url.ts @@ -1,20 +1,13 @@ +// https://github.com/validatorjs/validator.js/blob/master/README.md#validators + +import isURL from "validator/lib/isURL"; + interface IValidUrl { url: string; - /** Validate protocol specified; http validates both http and https */ - protocol?: "http" | "https"; + /** Validate protocols specified */ + protocols?: ("http" | "https")[]; } -export default ({ url, protocol }: IValidUrl): boolean => { - try { - const newUrl = new URL(url); - if (protocol === "http") { - return newUrl.protocol === "http:" || newUrl.protocol === "https:"; - } - if (protocol === "https") { - return newUrl.protocol === "https:"; - } - return true; - } catch (e) { - return false; - } +export default ({ url, protocols }: IValidUrl): boolean => { + return isURL(url, { protocols }); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx index 1d4bad9950..dee63d4826 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx @@ -89,7 +89,7 @@ const IntegrationForm = ({ const validateForm = () => { let error = null; - if (url && !validUrl({ url, protocol: "https" })) { + if (url && !validUrl({ url, protocols: ["https"] })) { error = `${url} is not a valid HTTPS URL`; } diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx index 3cba376e00..fb91398e9c 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx @@ -61,13 +61,18 @@ const Info = ({ errors.org_name = "Organization name must be present"; } - if (orgLogoURL && !validUrl({ url: orgLogoURL, protocol: "http" })) { + if ( + orgLogoURL && + !validUrl({ url: orgLogoURL, protocols: ["http", "https"] }) + ) { errors.org_logo_url = `${orgLogoURL} is not a valid URL`; } if (!orgSupportURL) { errors.org_support_url = `Organization support URL must be present`; - } else if (!validUrl({ url: orgSupportURL, protocol: "http" })) { + } else if ( + !validUrl({ url: orgSupportURL, protocols: ["http", "https"] }) + ) { errors.org_support_url = `${orgSupportURL} is not a valid URL`; } diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Sso/Sso.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Sso/Sso.tsx index 8df0cacf07..929f08011a 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Sso/Sso.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Sso/Sso.tsx @@ -74,7 +74,9 @@ const Sso = ({ if (!metadataUrl) { errors.metadata_url = "Metadata or Metadata URL must be present"; errors.metadata = "Metadata or Metadata URL must be present"; - } else if (!validUrl({ url: metadataUrl, protocol: "http" })) { + } else if ( + !validUrl({ url: metadataUrl, protocols: ["http", "https"] }) + ) { errors.metadata_url = `${metadataUrl} is not a valid URL`; } } diff --git a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx index 2709f67d2a..cc2660f30b 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx @@ -36,7 +36,7 @@ const WebAddress = ({ const errors: IAppConfigFormErrors = {}; if (!serverURL) { errors.server_url = "Fleet server URL must be present"; - } else if (!validUrl({ url: serverURL, protocol: "http" })) { + } else if (!validUrl({ url: serverURL, protocols: ["http", "https"] })) { errors.server_url = `${serverURL} is not a valid URL`; } diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx index 7e3285d87d..9ffbd5fa55 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx @@ -75,7 +75,7 @@ const CalendarEventsModal = ({ const { url: newUrl } = newFormData; if ( formData.enabled && - !validURL({ url: newUrl || "", protocol: "http" }) + !validURL({ url: newUrl || "", protocols: ["http", "https"] }) ) { const errorPrefix = newUrl ? `${newUrl} is not` : "Please enter"; errors.url = `${errorPrefix} a valid resolution webhook URL`; diff --git a/package.json b/package.json index 01622139c3..29ac16831a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "sqlite-parser": "1.0.1", "use-debounce": "9.0.4", "uuid": "8.3.2", + "validator": "13.11.0", "when": "3.7.8" }, "devDependencies": { @@ -116,6 +117,7 @@ "@types/react-tooltip": "4.2.4", "@types/sockjs-client": "1.5.1", "@types/uuid": "8.3.4", + "@types/validator": "13.11.9", "@typescript-eslint/eslint-plugin": "5.58.0", "@typescript-eslint/parser": "5.58.0", "autoprefixer": "10.4.19", diff --git a/yarn.lock b/yarn.lock index 4932b7c84a..117249d2cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5363,6 +5363,11 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== +"@types/validator@13.11.9": + version "13.11.9" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.11.9.tgz#adfe96520b437a0eaa798a475877bf2f75ee402d" + integrity sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw== + "@types/wait-on@^5.2.0": version "5.3.3" resolved "https://registry.yarnpkg.com/@types/wait-on/-/wait-on-5.3.3.tgz#e0cb6ddc3f7fade127f0d8cb295499f75e65b849" @@ -15799,7 +15804,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15813,6 +15818,13 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16811,6 +16823,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@13.11.0: + version "13.11.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" + integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ== + value-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" From 93fde03717d498ad081fa119010f566c3b827109 Mon Sep 17 00:00:00 2001 From: George Karr Date: Thu, 25 Apr 2024 12:36:54 -0500 Subject: [PATCH 29/83] Update CHANGELOG.md (#18544) --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0717507c0a..2989ae97ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ - Updated `fleetctl run-script` to include new `--team` and `--script-name` flags. - Displayed disk encryption status in macOS as "verifying" while verifying the escrowed key. - Added the `enable_release_device_manually` configuration setting for teams and no team, which controls the automatic release of a macOS DEP-enrolled device. -- Updated the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host. -- Added the `enable_release_device_manually` configuration, which affects macOS automatic enrollment profile settings. ### Vulnerability management From 28e501cd3c27abcf952b7dfc6cfec26e3019c488 Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Thu, 25 Apr 2024 20:19:03 +0200 Subject: [PATCH 30/83] Docs change: Use new Software Update (DDM) for macOS Sonoma (14) and higher (#17487) Docs changes related to: - #17295 --- docs/Using Fleet/MDM-OS-updates.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/Using Fleet/MDM-OS-updates.md b/docs/Using Fleet/MDM-OS-updates.md index c10adb06a3..6986593c76 100644 --- a/docs/Using Fleet/MDM-OS-updates.md +++ b/docs/Using Fleet/MDM-OS-updates.md @@ -22,6 +22,12 @@ 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. + +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) + 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) From de269739538e98584a83835041efd5ec9c7a3885 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 25 Apr 2024 13:20:01 -0500 Subject: [PATCH 31/83] #17148 API changes: See if scripts are enabled for a host (#17813) For #17148. --- docs/REST API/rest-api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index a36a0d2325..e724af49d5 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -2396,6 +2396,7 @@ Returns the information of the specified host. "percent_disk_space_available": 74, "gigs_total_disk_space": 160, "disk_encryption_enabled": true, + "scripts_enabled": true, "users": [ { "uid": 0, From df7eafb4d52bf0252ea9604914e68b77e667e65e Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Thu, 25 Apr 2024 13:20:15 -0500 Subject: [PATCH 32/83] Pin alpine base image to 3.19.1 (#18347) https://hub.docker.com/layers/library/alpine/3.19.1/images/sha256-6457d53fb065d6f250e1504b9bc42d5b6c65941d57532c072d929dd0628977d0 Fixes https://scout.docker.com/vulnerabilities/id/CVE-2024-2511?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.19&vr=%3C3.1.4-r6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c05bdff5e5..fb9e439674 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.18.2@sha256:82d1e9d7ed48a7523bdebc18cf6290bdb97b82302a8a9c27d4fe885949ea94d1 +FROM alpine:3.19.1@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b LABEL maintainer="Fleet Developers" RUN apk --update add ca-certificates From 1657b38eba95ef458d511fda2ad054590d315341 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 25 Apr 2024 13:26:28 -0500 Subject: [PATCH 33/83] #17361 API changes: Add fleetd version to host details (#17814) For #17361. --------- Co-authored-by: Victor Lyuboslavsky --- docs/REST API/rest-api.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index e724af49d5..27f177f074 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -2364,7 +2364,10 @@ Returns the information of the specified host. "hostname": "23cfc9caacf0", "uuid": "309a4b7d-0000-0000-8e7f-26ae0815ede8", "platform": "rhel", - "osquery_version": "4.5.1", + "osquery_version": "5.12.0", + "orbit_version": "1.22.0", + "fleet_desktop_version": "1.22.0", + "scripts_enabled": true, "os_version": "CentOS Linux 8.3.2011", "build": "", "platform_like": "rhel", @@ -2541,6 +2544,12 @@ Returns the information of the specified host. > Note: `installed_paths` may be blank depending on installer package. For example, on Linux, RPM-installed packages do not provide installed path information. +> Note: +> - `orbit_version: null` means this agent is not a fleetd agent +> - `fleet_desktop_version: null` means this agent is not a fleetd agent, or this agent is version <=1.23.0 which is not collecting the desktop version +> - `fleet_desktop_version: ""` means this agent is a fleetd agent but does not have fleet desktop +> - `scripts_enabled: null` means this agent is not a fleetd agent, or this agent is version <=1.23.0 which is not collecting the scripts enabled info + ### Get host by identifier Returns the information of the host specified using the `uuid`, `hardware_serial`, `osquery_host_id`, `hostname`, or From ef767de2f93f2eab1841b2d95c12bb88bcfd9e1e Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Thu, 25 Apr 2024 14:56:30 -0400 Subject: [PATCH 34/83] Update pricing-features-table.yml (#18250) - Deploy security agents coming next sprint - Plan for customer-reedtimmer has changed to hosting S3 bucket for Munki --- handbook/company/pricing-features-table.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index d22f054d7a..1621f3a65b 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -632,7 +632,7 @@ description: Easily configure and install SentinelOne, Crowdstrike, and other security tools. moreInfoUrl: https://github.com/fleetdm/fleet/issues/14921 tier: Premium - comingSoonOn: 2024-04-22 #customer-reedtimmer,customer-flacourtia + comingSoonOn: 2024-05-13 #customer-flacourtia usualDepartment: IT productCategories: [Device management] pricingTableCategories: [Device management] From 08866bcc1cd8804680e81560d45fb3e0e889cc88 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Thu, 25 Apr 2024 16:27:34 -0300 Subject: [PATCH 35/83] fix js-lint CI check (#18542) it's currently failing on `main` --- .../EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx | 3 ++- .../pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx | 6 +++--- .../AgentOptionsPage/AgentOptionsPage.tsx | 5 +++-- frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx | 3 ++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx b/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx index 1c32637a17..5f33058079 100644 --- a/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx +++ b/frontend/components/EnrollSecrets/DeleteSecretModal/DeleteSecretModal.tsx @@ -50,7 +50,8 @@ const DeleteSecretModal = ({ unable to enroll.

- Hosts that enrolled with this secret will not get updates to agent options. + Hosts that enrolled with this secret will not get updates to agent + options.

Follow this guide to{" "} diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx index 1a57ef5576..135f72ff63 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Agents/Agents.tsx @@ -81,9 +81,9 @@ const Agents = ({

- Agent options configure Fleet's agent (fleetd). When you update agent - options, they will be applied the next time a host checks in to - Fleet.{" "} + Agent options configure Fleet's agent (fleetd). When you update + agent options, they will be applied the next time a host checks in + to Fleet.{" "}

- Agent options configure Fleet's agent (fleetd). When you update agent - options, they will be applied the next time a host checks in to Fleet. + Agent options configure Fleet's agent (fleetd). When you update + agent options, they will be applied the next time a host checks in to + Fleet.
Add hosts From 3886a322fd490d89c117fa86400e0c85a53378c6 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Thu, 25 Apr 2024 14:34:02 -0500 Subject: [PATCH 36/83] Move freeze start time to 11am pacific (#18549) --- handbook/engineering/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 2b72932d2b..63b7ce87e6 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -48,7 +48,7 @@ If the bug is labeled `~unreleased bug`, branch off and put your PR into `main`. If the bug is labeled `~released bug`, branch off the tag for the latest release of Fleet and put your PR into `main`. For example, `git checkout fleet-v4.48.2`, then `git checkout -b my-bug-fix-branch`. These issues are not closed until the next release of Fleet. This approach makes sure the bug fix is not built on top of unreleased feature code, which can cause merge conflicts during patch releases. ### Begin a merge freeze -To ensure release quality, Fleet has a freeze period for testing beginning the Tuesday before the release at 9:00 AM Pacific. Effective at the start of the freeze period, new feature work will not be merged into `main`. +To ensure release quality, Fleet has a freeze period for testing beginning the Tuesday before the release at 11:00 AM Pacific. Effective at the start of the freeze period, new feature work will not be merged into `main`. Bugs are exempt from the release freeze period. From a0a8579b56f98ba693cca94fc0f27ea4a10af858 Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Thu, 25 Apr 2024 22:53:01 +0200 Subject: [PATCH 37/83] API design: API endpoint for updating a host's manual labels (#16979) API design for: - #16767 --------- Co-authored-by: Rachael Shaw --- docs/REST API/rest-api.md | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 27f177f074..bf9887808b 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -1865,6 +1865,8 @@ None. - [Wipe host](#wipe-host) - [Get host's past activity](#get-hosts-past-activity) - [Get host's upcoming activity](#get-hosts-upcoming-activity) +- [Add labels to host](#add-labels-to-host) +- [Remove labels from host](#remove-labels-from-host) - [Live query one host (ad-hoc)](#live-query-one-host-ad-hoc) - [Live query host by identifier (ad-hoc)](#live-query-host-by-identifier-ad-hoc) @@ -4037,6 +4039,64 @@ To wipe a macOS or Windows host, the host must have MDM turned on. To lock a Lin } ``` +### Add labels to host + +Adds manual labels to a host. + +`POST /api/v1/fleet/hosts/:id/labels` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | ---------------------------- | +| labels | list | body | The list of label names to add to the host. | + + +#### Example + +`POST /api/v1/fleet/hosts/12/labels` + +##### Request body + +```json +{ + "labels": ["label1", "label2"] +} +``` + +##### Default response + +`Status: 200` + +### Remove labels from host + +Removes manual labels from a host. + +`DELETE /api/v1/fleet/hosts/:id/labels` + +#### Parameters + +| Name | Type | In | Description | +| ---- | ------- | ---- | ---------------------------- | +| labels | list | body | The list of label names to delete from the host. | + + +#### Example + +`DELETE /api/v1/fleet/hosts/12/labels` + +##### Request body + +```json +{ + "labels": ["label3", "label4"] +} +``` + +##### Default response + +`Status: 200` + ### Live query one host (ad-hoc) Runs an ad-hoc live query against the specified host and responds with the results. From 255cea08ccbcda8db89b04b4bea1aefd626fb59f Mon Sep 17 00:00:00 2001 From: Jason Lewis <57552211+Patagonia121@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:56:11 -0700 Subject: [PATCH 38/83] removing CSE role from website (#18557) --- handbook/company/open-positions.yml | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index cd91548db0..7d313763f6 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -61,32 +61,5 @@ - 🛠️ Technical: You understand the software development processes. - 🟣 Openness: You are flexible and open to new ideas and ways of working - ➕ Bonus: Cybersecurity or IT background -- jobTitle: 🐋 Customer Success Engineer - department: Customers - hiringManagerName: Jason Lewis - hiringManagerGithubUsername: Patagonia121 - hiringManagerLinkedInUrl: https://www.linkedin.com/in/jlewis0451/ - responsibilities: | - - 🎯 Strong attention to detail and can act as an encyclopedia of knowledge about how Fleet works - our customers represent a wide range of needs across many different use cases. Be adaptable to learning new things quickly and then share this knowledge with others. - - 📣 Manage multiple customer deployments and escalations simultaneously with the ability to stay organized. - - 🚀 Deploy Fleet on your own to have a better understanding of the customer experience and how the product works. - - 🪴 Promote product adoption, referencability, and customer advocacy with key customer stakeholders. - - 🥇 Be the first line of defense in customer Slack channels for any reported problems, how-to questions, feature request intake, and bug report filling. - - 🚀 Work collaboratively with product and engineering teams to facilitate bug resolution and feature development based on customer asks. - - ⏫ Work hand-in-hand with the customer success team by participating in ad-hoc calls with customers to discuss any support issues they may have. - - 💡 Excellent communication and collaboration skills, with the ability to work cross-functionally with CS, engineering, and product teams. - experience: | - - 💭 Cybersecurity or IT background, experience with cloud environments like AWS and Azure or device management solutions like Fleet, Intune, Jamf Pro, Workspace One, etc. - - 💖 You know how to manage your time and priorities between customer support engagements, customer escalations, and other day-to-day responsibilities. - - 🧬 An excellent understanding of macOS, Windows, Linux and core services like Autopilot, ABM/ASM, MDM, ADE, APNs, syslog, etc. - - 🤝 You work best in a team-based environment. You are decisive with the ability to shift gears between thinking and doing. - - 👥 A customer-centric mindset, focusing on delivering value and a positive user experience. - - 🦉 2-3 years of work experience providing technical support to enterprise customers in the cybersecurity or device management space. Experience with executing and tracking results tied to customer escalations. - - 🛠️ You are personable, enjoy being customer facing, and have a passion for problem solving while assisting external and internal stakeholders. - - 🧪 Extensive experience with Slack, Google Suite, and GitHub. - - ✍️ Familiarity with shell scripting, Python, Powershell, and using Terminal to execute commands or run scripts, and other line of business applications. - - 🟣 Openness: Speak freely. Interrupt and be interrupted. Give pointed and respectful feedback, even when you disagree. - - 🔴 Empathy: You should demonstrate empathy by keenly understanding and addressing customer concerns with genuine compassion. - - ➕ Bonus: Familiarity with osquery, MySQL, GitOps workflows, Terraform, Tines/Torq and open source projects. Experience working with IT, SRE, CPE, or SecOps teams. From e4f1339d6e28403cd383f5bc08877677085182d6 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Fri, 26 Apr 2024 01:50:31 -0500 Subject: [PATCH 39/83] Website: sails.helpers.iq.getEnriched() and use code instead of nocode (#18538) Co-authored-by: Eric --- .../deliver-contact-form-message.js | 6 + .../controllers/deliver-mdm-beta-signup.js | 2 +- .../deliver-talk-to-us-form-submission.js | 9 + website/api/controllers/entrance/signup.js | 8 + .../save-questionnaire-progress.js | 65 +++--- website/api/helpers/iq/get-enriched.js | 190 ++++++++++++++++ website/api/helpers/salesforce/create-lead.js | 90 ++++++++ website/api/helpers/salesforce/create-task.js | 45 ++++ .../update-or-create-contact-and-account.js | 205 ++++++++++++++++++ website/config/custom.js | 77 ++++--- website/package.json | 1 + 11 files changed, 634 insertions(+), 64 deletions(-) create mode 100644 website/api/helpers/iq/get-enriched.js create mode 100644 website/api/helpers/salesforce/create-lead.js create mode 100644 website/api/helpers/salesforce/create-task.js create mode 100644 website/api/helpers/salesforce/update-or-create-contact-and-account.js diff --git a/website/api/controllers/deliver-contact-form-message.js b/website/api/controllers/deliver-contact-form-message.js index a746c93627..532308baf2 100644 --- a/website/api/controllers/deliver-contact-form-message.js +++ b/website/api/controllers/deliver-contact-form-message.js @@ -71,6 +71,12 @@ module.exports = { `Name: ${firstName + ' ' + lastName}, Email: ${emailAddress}, Message: ${message ? message : 'No message.'}` }); + await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: emailAddress, + firstName: firstName, + lastName: lastName, + }); + // Send a POST request to Zapier await sails.helpers.http.post( diff --git a/website/api/controllers/deliver-mdm-beta-signup.js b/website/api/controllers/deliver-mdm-beta-signup.js index 5965858d9d..78eb54780b 100644 --- a/website/api/controllers/deliver-mdm-beta-signup.js +++ b/website/api/controllers/deliver-mdm-beta-signup.js @@ -1,6 +1,6 @@ module.exports = { - + // TODO: This isn't a thing anymore, we can delete it. friendlyName: 'Deliver MDM beta signup', 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 42abe97909..d07f2474b4 100644 --- a/website/api/controllers/deliver-talk-to-us-form-submission.js +++ b/website/api/controllers/deliver-talk-to-us-form-submission.js @@ -73,6 +73,15 @@ module.exports = { if(_.includes(sails.config.custom.bannedEmailDomainsForWebsiteSubmissions, emailDomain.toLowerCase())){ throw 'invalidEmailDomain'; } + + await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress, + firstName, + lastName, + organization: organization, + primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : 'Vulnerability management', + }); + await sails.helpers.http.post.with({ url: 'https://hooks.zapier.com/hooks/catch/3627242/3cxwxdo/', data: { diff --git a/website/api/controllers/entrance/signup.js b/website/api/controllers/entrance/signup.js index c5f36303f5..e20c3252e1 100644 --- a/website/api/controllers/entrance/signup.js +++ b/website/api/controllers/entrance/signup.js @@ -138,6 +138,14 @@ the account verification message.)`, .intercept({name: 'UsageError'}, 'invalid') .fetch(); + + await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: newEmailAddress, + firstName: firstName, + lastName: lastName, + organization: organization, + }); + // Send a POST request to Zapier await sails.helpers.http.post.with({ url: 'https://hooks.zapier.com/hooks/catch/3627242/30bq2ib/', diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index f89b1b116e..236b8c5865 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -63,22 +63,6 @@ module.exports = { .set({ primaryBuyingSituation: primaryBuyingSituation }); - // Send a POST request to Zapier - await sails.helpers.http.post.with({ - url: 'https://hooks.zapier.com/hooks/catch/3627242/3pl7yt1/', - data: { - primaryBuyingSituation, - emailAddress: this.req.me.emailAddress, - webhookSecret: sails.config.custom.zapierSandboxWebhookSecret, - } - }) - .timeout(5000) - .tolerate(['non200Response', 'requestFailed'], (err)=>{ - // Note that Zapier responds with a 2xx status code even if something goes wrong, so just because this message is not logged doesn't mean everything is hunky dory. More info: https://github.com/fleetdm/fleet/pull/6380#issuecomment-1204395762 - sails.log.warn(`When a user completed the 'What are you using Fleet for' questionnaire step, a lead/contact could not be updated in the CRM for this email address: ${this.req.me.emailAddress}. Raw error: ${err}`); - return; - }); - // Set the primary buying situation in the user's session. this.req.session.primaryBuyingSituation = primaryBuyingSituation; }//fi @@ -147,26 +131,37 @@ module.exports = { // If the user selects let me think about it, their stage will not change. }//fi - // Send a POST request to Zapier - await sails.helpers.http.post.with({ - url: 'https://hooks.zapier.com/hooks/catch/3627242/3nltwbg/', - data: { - emailAddress: this.req.me.emailAddress, - firstName: this.req.me.firstName, - lastName: this.req.me.lastName, - primaryBuyingSituation: primaryBuyingSituation, - organization: this.req.me.organization, - psychologicalStage, - currentStep, - webhookSecret: sails.config.custom.zapierSandboxWebhookSecret, - } - }) - .timeout(5000) - .tolerate(['non200Response', 'requestFailed'], (err)=>{ - // Note that Zapier responds with a 2xx status code even if something goes wrong, so just because this message is not logged doesn't mean everything is hunky dory. More info: https://github.com/fleetdm/fleet/pull/6380#issuecomment-1204395762 - sails.log.warn(`When a user completed a questionnaire step, a lead/contact could not be updated in the CRM for this email address: ${this.req.me.emailAddress}. Raw error: ${err}`); - return; + + + await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: this.req.me.emailAddress, + firstName: this.req.me.firstName, + lastName: this.req.me.lastName, + primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : 'Vulnerability management', + organization: this.req.me.organization, + psychologicalStage, }); + // TODO: send all other answers to Salesforce (when there are fields for them) + + // await sails.helpers.http.post.with({ + // url: 'https://hooks.zapier.com/hooks/catch/3627242/3nltwbg/', + // data: { + // emailAddress: this.req.me.emailAddress, + // firstName: this.req.me.firstName, + // lastName: this.req.me.lastName, + // primaryBuyingSituation: primaryBuyingSituation, + // organization: this.req.me.organization, + // psychologicalStage, + // currentStep, + // webhookSecret: sails.config.custom.zapierSandboxWebhookSecret, + // } + // }) + // .timeout(5000) + // .tolerate(['non200Response', 'requestFailed'], (err)=>{ + // // Note that Zapier responds with a 2xx status code even if something goes wrong, so just because this message is not logged doesn't mean everything is hunky dory. More info: https://github.com/fleetdm/fleet/pull/6380#issuecomment-1204395762 + // sails.log.warn(`When a user completed a questionnaire step, a lead/contact could not be updated in the CRM for this email address: ${this.req.me.emailAddress}. Raw error: ${err}`); + // return; + // }); // 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. diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js new file mode 100644 index 0000000000..bc6411059f --- /dev/null +++ b/website/api/helpers/iq/get-enriched.js @@ -0,0 +1,190 @@ +module.exports = { + + + friendlyName: 'Get enriched', + + + description: 'Search for the contact indicated and return enriched data.', + + + inputs: { + + emailAddress: { type: 'string', defaultsTo: '', }, + linkedinUrl: { type: 'string', defaultsTo: '', }, + firstName: { type: 'string', defaultsTo: '', }, + lastName: { type: 'string', defaultsTo: '', }, + organization: { type: 'string', defaultsTo: '', }, + + }, + + + exits: { + + success: { + outputFriendlyName: 'Report', + outputDescription: 'All available, enriched info about this person and their current employer.', + outputType: { + person: { + emailAddress: 'string', + linkedinUrl: 'string', + firstName: 'string', + lastName: 'string', + organization: 'string', + title: 'string', + phone: 'string', + }, + employer: { + organization: 'string', + numberOfEmployees: 'number', + emailDomain: 'string', + linkedinCompanyPageUrl: 'string', + technologies: [{name: 'string', category: 'string'}] + } + } + }, + + }, + + + fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { + require('assert')(sails.config.custom.iqSecret); + + let RX_TECHNOLOGY_CATEGORIES = /(device|security|endpoint|configuration management|data management platforms|mobility management|identity|information technology|IT$|employee experience|apple)/i; + + // [?] https://developer.leadiq.com/#query-searchPeople + // [?] https://developer.leadiq.com/#definition-SearchPeopleInput + // [?] https://graphql.org/learn/serving-over-http/ + let emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; + + let searchExpr = `{ + ${emailAddress? 'email: '+ JSON.stringify(emailAddress) : ''} + ${linkedinUrl? 'linkedinUrl: '+ JSON.stringify(linkedinUrl) : ''} + ${firstName? 'firstName: '+ JSON.stringify(firstName) : ''} + ${lastName? 'lastName: '+ JSON.stringify(lastName) : ''} + ${organization || emailDomain ? (`company: { + ${organization? 'name: '+ JSON.stringify(organization) : ''} + ${emailDomain? 'domain: '+ JSON.stringify(emailDomain)+' '+'emailDomain: '+ JSON.stringify(emailDomain) : ''} + searchInPastCompanies: false + strict: false + }`) : ''} + }`; //sails.log('GraphQL query:',searchExpr); + let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { + query: `{ searchPeople(input: ${searchExpr}) { + totalResults + results { + _id + name { first last } + linkedin { linkedinId linkedinUrl status updatedAt } + profiles { network id username url status updatedAt } + location { country areaLevel1 city fullAddress type status updatedAt } + personalPhones { value type status verificationStatus } + currentPositions { + title + emails { value type status } + phones { value type status verificationStatus } + companyInfo { + name + domain + country + address + linkedinUrl + numberOfEmployees + technologies { name category parentCategory attributes categories } + } + } + } + } + }`, + }, { + Authorization: `Basic ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }); + + if (report.errors) { + sails.log.warn('Errors returned from IQ API when attempting to search for a matching contact:',report.errors); + } + + // sails.log('person search results:',require('util').inspect(report.data.searchPeople.results, {depth:null})); + let foundPerson = report.data.searchPeople.results[0]; //sails.log('Found person:',foundPerson); + let foundPosition = foundPerson && foundPerson.currentPositions && foundPerson.currentPositions.length >= 1 ? foundPerson.currentPositions[0] : undefined; + + let person; + if (foundPerson) { + person = { + emailAddress: emailAddress? emailAddress : foundPosition && foundPosition.emails[0]? foundPosition.emails[0].value : '', + linkedinUrl: linkedinUrl? linkedinUrl : foundPerson.linkedin.linkedinUrl, + firstName: firstName? firstName : foundPerson.name.first, + lastName: lastName? lastName : foundPerson.name.last, + organization: organization? organization : foundPosition? foundPosition.companyInfo.name : '', + title: foundPosition? foundPosition.title : '', + phone: foundPerson.personalPhones[0] && foundPerson.personalPhones[0].status !== 'Suppressed' ? foundPerson.personalPhones[0].value : '', + }; + }//fi + + + + + + // If no person was found, then try and look up the organization by itself. + let employer; + if (foundPosition) { + employer = { + organization: organization? organization : foundPosition.companyInfo.name || '', + numberOfEmployees: foundPosition.companyInfo.numberOfEmployees || 0, + emailDomain:( foundPosition.companyInfo.domain? foundPosition.companyInfo.domain : emailDomain )|| '', + linkedinCompanyPageUrl: foundPosition.companyInfo.linkedinUrl || '', + technologies: foundPosition.companyInfo.technologies? foundPosition.companyInfo.technologies + .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) + .map((tech) => ({ name: tech.name, category: tech.category })) : [] + }; + } else { + let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { + query: `{ searchCompany(input: { + ${organization? 'name: '+ JSON.stringify(organization) : ''} + ${emailDomain? 'domain: '+ JSON.stringify(emailDomain) : ''} + }) { + totalResults + results { + name + domain + country + address + numberOfEmployees + linkedinUrl + technologies { name category parentCategory attributes categories } + } + } + }` + }, { + Authorization: `Basic ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }); + // sails.log('company search report:',report); + + if (report.errors) { + sails.log.warn('Errors returned from IQ API when attempting to search directly for a matching organization:',report.errors); + } + let foundEmployer = report.data.searchCompany.results[0]; //sails.log(foundEmployer); + if (foundEmployer) { + employer = { + organization: organization? organization : foundEmployer.name || '', + numberOfEmployees: foundEmployer.numberOfEmployees || 0, + emailDomain: emailDomain? emailDomain : foundEmployer.domain || '', + linkedinCompanyPageUrl: foundEmployer.linkedinUrl || '', + technologies: foundEmployer.technologies? foundEmployer.technologies + .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) + .map((tech) => ({ name: tech.name, category: tech.category })) : [] + };// process.stdout.write(JSON.stringify(employer.technologies,0,2)); + } + }//fi + + return { + person, + employer + }; + + } + + +}; + diff --git a/website/api/helpers/salesforce/create-lead.js b/website/api/helpers/salesforce/create-lead.js new file mode 100644 index 0000000000..8ae92c399a --- /dev/null +++ b/website/api/helpers/salesforce/create-lead.js @@ -0,0 +1,90 @@ +module.exports = { + + + friendlyName: 'Create lead',// FUTURE: Retire this in favor of createTask() + + + description: 'Create a Lead record in Salesforce representing some kind of action Fleet needs to take for someone, whether based on a signal from their behavior or their explicit request.', + + + inputs: { + + salesforceAccountId: { type: 'string', required: true }, + salesforceContactId: { type: 'string', required: true }, + 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: { type: 'string', required: true, isIn: ['Website - Contact forms', 'Website - Sign up', 'Website - Waitlist', 'Website - swag request'], },// TODO verify and complete enum + + + // FUTURE: Move these off eventually: + firstName: { type: 'string', required: true, description: 'The first name of the referenced contact.' }, + lastName: { type: 'string', required: true, description: 'The last name of the referenced contact.' }, + emailAddress: { type: 'string', description: 'The email address of the referenced contact.', extendedDescription: 'Included here so that the little Salesforce thingie that shows email and calendar activity shows maximum contact in both the Contact and Lead views.' }, + primaryBuyingSituation: { type: 'string' }, + numberOfHosts: { type: 'number' }, + + }, + + + exits: { + + success: { + extendedDescription: 'Note that this deliberately has no return value.', + }, + + }, + + + fn: async function ({salesforceAccountId, salesforceContactId, leadDescription, leadSource, firstName, lastName, emailAddress, primaryBuyingSituation, numberOfHosts}) { + require('assert')(sails.config.custom.salesforceIntegrationUsername); + require('assert')(sails.config.custom.salesforceIntegrationPasskey); + let jsforce = require('jsforce'); + console.log(firstName, lastName, emailAddress, primaryBuyingSituation, numberOfHosts); + let salesforceConnection = new jsforce.Connection({ + loginUrl : 'https://fleetdm.my.salesforce.com' + }); + await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); + // Get the contact record + let contactRecord = await salesforceConnection.sobject('Contact') + .retrieve(salesforceContactId); + // Verify that the account ID provided is valid. + let accountRecord = await salesforceConnection.sobject('Account') + .retrieve(salesforceAccountId); + + // TODO better error messages + if(contactRecord === null) { + throw new Error(`When attempting to create a Salesforce lead using the ID of a Contact record, no Contact matching the id provided (${salesforceContactId} was found.`); + } + if(accountRecord === null) { + throw new Error(`When attempting to create a Salesforce lead, no account matching the id provided (${salesforceContactId} could be found`); + } + + // TODO: wrap this in a try-catch block to handle errors from Salesforce. + // Create the new Lead record. + let lead = await salesforceConnection.sobject('Lead') + .create({ + FirstName: contactRecord.FirstName, + LastName: contactRecord.LastName, + Email: contactRecord.Email, + Website: contactRecord.Website, + // eslint-disable-next-line camelcase + of_hosts__c: contactRecord.of_hosts__c, + // eslint-disable-next-line camelcase + Primary_buying_scenario__c: contactRecord.Primary_buying_situation__c, + // eslint-disable-next-line camelcase + LinkedIn_profile__c: contactRecord.LinkedIn_profile__c, + Description: leadDescription, + LeadSource: leadSource, + // eslint-disable-next-line camelcase + Contact_associated_by_website__c: salesforceContactId, + // eslint-disable-next-line camelcase + Account__c: salesforceAccountId, + OwnerId: accountRecord.OwnerId + }); + console.log(`Created lead! ${lead}`); + + // TODO handle duplicate leads: + } + + +}; + diff --git a/website/api/helpers/salesforce/create-task.js b/website/api/helpers/salesforce/create-task.js new file mode 100644 index 0000000000..f509094db2 --- /dev/null +++ b/website/api/helpers/salesforce/create-task.js @@ -0,0 +1,45 @@ +module.exports = { + + // TODO: Change this into "create activity" instead (and create github issues for tasks instead) + friendlyName: 'Create task', + + + description: 'Create a task for our team related to a particular account in Salesforce.', + + + inputs: { + salesforceAccountId: { type: 'string', required: true, extendedDescription: 'This account will be used to determine the assignee (owner) for this new task. (The account\'s owner will also be the owner for the new task.)' }, + dueDate: { type: 'string', example: 'YYYY-MM-DD', extendedDescription: 'If unspecified, defaults to the current date.', regex: /^[0-9][0-9][0-9][0-9]\-[0-9][0-9]\-[0-9][0-9]$/ }, + }, + + + exits: { + + success: { + extendedDescription: 'Note that this deliberately has no return value.', + }, + + }, + + + fn: async function ({ salesforceAccountId, dueDate }) { + sails.log(salesforceAccountId, dueDate); + throw new Error('Not yet implemented'); + + // require('assert')(sails.config.custom.salesforceSecret); + + // let jsforce = require('jsforce'); + // let conn = new jsforce.Connection({ /* */ }); + // let userInfo = await conn.login('___________', '____________');// TODO + // let salesforceIntegrationUserId = userInfo.userId; + + // await conn.sobject('Task').create({ + // DueDate: dueDate, + // ActivityDate: jsforce.Date.TODAY + // }); + + } + + +}; + 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 new file mode 100644 index 0000000000..b514e2bae0 --- /dev/null +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account.js @@ -0,0 +1,205 @@ +module.exports = { + + + friendlyName: 'Update or create contact and account', + + + description: 'Upsert contact±account into Salesforce given fresh data about a particular person, and fresh IQ-enrichment data about the person and account.', + + + inputs: { + + // Find by… + emailAddress: { type: 'string' }, + linkedinUrl: { type: 'string' }, + + // Set… + firstName: { type: 'string', required: true }, + lastName: { type: 'string', required: true }, + organization: { type: 'string' }, + primaryBuyingSituation: { type: 'string' }, + psychologicalStage: { + type: 'string', + isIn: [ + '1 - Unaware', + '2 - Aware', + '3 - Intrigued', + '4 - Has use case', + '5 - Personally confident', + '6 - Has team buy-in' + ] + }, + }, + + + exits: { + + success: { + outputType: { + salesforceAccountId: 'string', + salesforceContactId: 'string' + } + }, + + }, + + + fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage}) { + require('assert')(sails.config.custom.salesforceIntegrationUsername); + require('assert')(sails.config.custom.salesforceIntegrationPasskey); + require('assert')(sails.config.custom.iqSecret); + + + if(!emailAddress && !linkedinUrl){ + throw new Error('UsageError: when updating or creating a contact and account in salesforce, either an email or linkedInUrl is required.'); + } + // Send the information we have to the enrichment helper. + let enrichmentData = await sails.helpers.iq.getEnriched(emailAddress, linkedinUrl, firstName, lastName, organization); + // console.log(enrichmentData); + + // Log in to Salesforce. + let jsforce = require('jsforce'); + let salesforceConnection = new jsforce.Connection({ + loginUrl : 'https://fleetdm.my.salesforce.com' + }); + + let salesforceAccountOwnerId; + + await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); + + let salesforceAccountId; + if(!organization && !enrichmentData.employer){ + // Special sacraficial meat cave where the contacts with no organization go. + // https://fleetdm.lightning.force.com/lightning/r/Account/0014x000025JC8DAAW/view + salesforceAccountId = '0014x000025JC8DAAW'; + salesforceAccountOwnerId = '0054x00000735wDAAQ'; + } else { + let existingAccountRecord = await salesforceConnection.sobject('Account') + .findOne({ + 'Website': enrichmentData.employer.emailDomain, + // 'LinkedIn_company_URL__c': enrichmentData.employer.linkedinCompanyPageUrl // TODO: if this information is not present on an existing account, nothing will be returned. + }); + // console.log(existingAccountRecord); + if(existingAccountRecord) { + // Store the ID of the Account record we found. + salesforceAccountId = existingAccountRecord.Id; + salesforceAccountOwnerId = existingAccountRecord.OwnerId; + // console.log('exising account found!', salesforceAccountId); + } else { + + + let roundRobinUsers = await salesforceConnection.sobject('User') + .find({ + AE_Round_robin__c: true,// eslint-disable-line camelcase + }); + let userWithEarliestAssignTimeStamp = _.sortBy(roundRobinUsers, 'AE_Account_Assignment_round_robin__c')[0]; + + let today = new Date(); + let nowOn = today.toISOString().replace('Z', '+0000'); + + salesforceAccountOwnerId = userWithEarliestAssignTimeStamp.Id; + + // Update this user to putthem atthe bottom of the round robin list. + await salesforceConnection.sobject('User') + .update({ + Id: salesforceAccountOwnerId, + // eslint-disable-next-line camelcase + AE_Account_Assignment_round_robin__c: nowOn + }); + // If no existing account record was found, create a new one. + let newAccountRecord = await salesforceConnection.sobject('Account') + .create({ + OwnerId: salesforceAccountOwnerId, + Account_Assigned_date__c: nowOn,// eslint-disable-line camelcase + // eslint-disable-next-line camelcase + Current_Assignment_Reason__c: 'Inbound Lead',// TODO verify that this matters. if not, do not set it. + Prospect_Status__c: 'Assigned',// eslint-disable-line camelcase + + Name: enrichmentData.employer.organization,// IFWMIH: We know organization exists + Website: enrichmentData.employer.emailDomain, + LinkedIn_company_URL__c: enrichmentData.employer.linkedinCompanyPageUrl,// eslint-disable-line camelcase + NumberOfEmployees: enrichmentData.employer.numberOfEmployees, + }); + salesforceAccountId = newAccountRecord.id; + // console.log('New account created!', salesforceAccountId); + } + } + + + + // Now search for an existing Contact. + // FUTURE: expand this section to improve the searches. + let existingContactRecord; + if(emailAddress){ + // console.log('searching for existing contact by emailAddress'); + existingContactRecord = await salesforceConnection.sobject('Contact') + .findOne({ + AccountId: salesforceAccountId, + Email: emailAddress, + }); + } else if(linkedinUrl) { + // console.log('searching for existing contact by linkedInUrl'); + existingContactRecord = await salesforceConnection.sobject('Contact') + .findOne({ + AccountId: salesforceAccountId, + LinkedIn_profile__c: linkedinUrl // eslint-disable-line camelcase + }); + } else { + existingContactRecord = undefined; + } + + let salesforceContactId; + let valuesToSet = {}; + if(emailAddress || enrichmentData.person){ + valuesToSet.Email = emailAddress || enrichmentData.person.emailAddress; + } + if(linkedinUrl || enrichmentData.person){ + valuesToSet.LinkedIn_profile__c = linkedinUrl || enrichmentData.person.linkedinUrl;// eslint-disable-line camelcase + } + if(enrichmentData.person){ + valuesToSet.Title = enrichmentData.person.title; + } + if(primaryBuyingSituation) { + valuesToSet.Primary_buying_situation__c = primaryBuyingSituation;// eslint-disable-line camelcase + } + if(psychologicalStage) { + valuesToSet.Stage__c = psychologicalStage;// eslint-disable-line camelcase + } + + + if(existingContactRecord){ + salesforceContactId = existingContactRecord.Id; + // console.log(`existing contact record found! ${salesforceContactId}`); + // Update the existing contact with the information provided. + await salesforceConnection.sobject('Contact') + .update({ + Id: salesforceContactId, + ...valuesToSet, + }); + // console.log(`${salesforceContactId} updated!`); + } else { + // Otherwise create a new Contact record. + let newContactRecord = await salesforceConnection.sobject('Contact') + .create({ + AccountId: salesforceAccountId, + OwnerId: salesforceAccountOwnerId, + FirstName: firstName, + LastName: lastName, + ...valuesToSet, + }); + // console.log(newContactRecord); + salesforceContactId = newContactRecord.id; + // console.log(`New contact record created! ${salesforceContactId}`); + } + + + return { + salesforceAccountId, + salesforceContactId + }; + + } + + +}; + diff --git a/website/config/custom.js b/website/config/custom.js index a4dd83723e..c46e9c634b 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -85,6 +85,13 @@ module.exports.custom = { // || (Or if you don't need billing, feel free to remove them.) //-------------------------------------------------------------------------- + + // Other integrations: + // openAiSecret: undefined, + // iqSecret: undefined, // You gotta use the base64-encoded API secret. (Get it in your account settings in LeadIQ.) + // salesforceIntegrationUsername: undefined, + // salesforceIntegrationPasskey: undefined, + // ██████╗ ██████╗ ██╗███████╗ // ██╔══██╗██╔══██╗██║██╔════╝ // ██║ ██║██████╔╝██║███████╗ @@ -286,10 +293,52 @@ module.exports.custom = { '/': ['lukeheath'] // Future update this }, + // ███████╗ ██████╗██╗ ██╗███████╗███╗ ███╗ █████╗ + // ██╔════╝██╔════╝██║ ██║██╔════╝████╗ ████║██╔══██╗ + // ███████╗██║ ███████║█████╗ ██╔████╔██║███████║ + // ╚════██║██║ ██╔══██║██╔══╝ ██║╚██╔╝██║██╔══██║ + // ███████║╚██████╗██║ ██║███████╗██║ ╚═╝ ██║██║ ██║ + // ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + // // The version of osquery to use when generating schema docs // (both in Fleet's query console and on fleetdm.com) versionOfOsquerySchemaToUseWhenGeneratingDocumentation: '5.11.0', + // ███████╗██╗ ██╗██████╗ ██╗ ██████╗ ██████╗ ███████╗ ██████╗ █████╗ ████████╗ █████╗ + // ██╔════╝╚██╗██╔╝██╔══██╗██║ ██╔═══██╗██╔══██╗██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ + // █████╗ ╚███╔╝ ██████╔╝██║ ██║ ██║██████╔╝█████╗ ██║ ██║███████║ ██║ ███████║ + // ██╔══╝ ██╔██╗ ██╔═══╝ ██║ ██║ ██║██╔══██╗██╔══╝ ██║ ██║██╔══██║ ██║ ██╔══██║ + // ███████╗██╔╝ ██╗██║ ███████╗╚██████╔╝██║ ██║███████╗ ██████╔╝██║ ██║ ██║ ██║ ██║ + // ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ + // + // Config variables in this section are used for the /try-fleet/explore-data page on fleetdm.com + + // For sending requests to a Fleet instance: + // fleetBaseUrlForQueryReports: '…', + // fleetTokenForQueryReports: '…', + + // The API ID of the team of hosts created for query reports. + // teamApidForQueryReports: + + // A dictionary where each key is the name of an osquery table, and the value is the API ID of the query that selects all information from that table. e.g., {'account_policy_data': 2045, 'ad_config': 2047, …} + // queryIdsByTableName: {…} + + // A dictionary where each key is the lowercased platform, and the value is the API ID of a host. e.g., {'macos': 92, 'windows': 94, 'linux': 93} + // hostIdsByHostPlatform: {…} + + // ███╗ ███╗██╗███████╗ ██████╗ + // ████╗ ████║██║██╔════╝██╔════╝ + // ██╔████╔██║██║███████╗██║ + // ██║╚██╔╝██║██║╚════██║██║ + // ██║ ╚═╝ ██║██║███████║╚██████╗ + // ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ + // + /*************************************************************************** + * * + * Any other custom config this Sails app should use during development. * + * (and possibly in ALL environments, if not overridden in config/env/) * + * * + ***************************************************************************/ // FUTURE: Consolidate these two lists of email domains (And maybe find another word for banned) // For the deliver-apple-csr webhook: @@ -339,34 +388,6 @@ module.exports.custom = { 'ymail.com', ], - // ███████╗██╗ ██╗██████╗ ██╗ ██████╗ ██████╗ ███████╗ ██████╗ █████╗ ████████╗ █████╗ - // ██╔════╝╚██╗██╔╝██╔══██╗██║ ██╔═══██╗██╔══██╗██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ - // █████╗ ╚███╔╝ ██████╔╝██║ ██║ ██║██████╔╝█████╗ ██║ ██║███████║ ██║ ███████║ - // ██╔══╝ ██╔██╗ ██╔═══╝ ██║ ██║ ██║██╔══██╗██╔══╝ ██║ ██║██╔══██║ ██║ ██╔══██║ - // ███████╗██╔╝ ██╗██║ ███████╗╚██████╔╝██║ ██║███████╗ ██████╔╝██║ ██║ ██║ ██║ ██║ - // ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ - // - // Config variables in this section are used for the /try-fleet/explore-data page on fleetdm.com - - // For sending requests to a Fleet instance: - // fleetBaseUrlForQueryReports: '…', - // fleetTokenForQueryReports: '…', - - // The API ID of the team of hosts created for query reports. - // teamApidForQueryReports: - - // A dictionary where each key is the name of an osquery table, and the value is the API ID of the query that selects all information from that table. e.g., {'account_policy_data': 2045, 'ad_config': 2047, …} - // queryIdsByTableName: {…} - - // A dictionary where each key is the lowercased platform, and the value is the API ID of a host. e.g., {'macos': 92, 'windows': 94, 'linux': 93} - // hostIdsByHostPlatform: {…} - - /*************************************************************************** - * * - * Any other custom config this Sails app should use during development. * - * (and possibly in ALL environments, if not overridden in config/env/) * - * * - ***************************************************************************/ // Contact form: // slackWebhookUrlForContactForm: '…', diff --git a/website/package.json b/website/package.json index 987acade2e..410c655571 100644 --- a/website/package.json +++ b/website/package.json @@ -8,6 +8,7 @@ "@sailshq/connect-redis": "^6.1.3", "@sailshq/lodash": "^3.10.5", "@sailshq/socket.io-redis": "^6.1.2", + "jsforce": "^1.11.1", "jsonwebtoken": "9.0.2", "moment": "2.29.4", "sails": "^1.5.10", From 262b5644ba3f756e409cffde4f7d3545d7366481 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 26 Apr 2024 02:00:23 -0500 Subject: [PATCH 40/83] Website: pin version of `jsforce` (#18559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - `"jsforce": "^1.11.1",` » `"jsforce": "1.11.1",` --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index 410c655571..a5f0902560 100644 --- a/website/package.json +++ b/website/package.json @@ -8,7 +8,7 @@ "@sailshq/connect-redis": "^6.1.3", "@sailshq/lodash": "^3.10.5", "@sailshq/socket.io-redis": "^6.1.2", - "jsforce": "^1.11.1", + "jsforce": "1.11.1", "jsonwebtoken": "9.0.2", "moment": "2.29.4", "sails": "^1.5.10", From 75511b3d26cd839c7051032ba267576e4fb76a63 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 26 Apr 2024 03:33:26 -0500 Subject: [PATCH 41/83] Website: update values passed into helper & don't run the Salesforce helper in development environment. (#18560) Changes: - Updated the default value that is being sent as a primaryBuyingSituation - Updated the updateOrCreateContactAndAccount Salesforce helper to do nothing in development. --- website/api/controllers/save-questionnaire-progress.js | 2 +- .../salesforce/update-or-create-contact-and-account.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index 236b8c5865..3c25b159c3 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -137,7 +137,7 @@ module.exports = { emailAddress: this.req.me.emailAddress, firstName: this.req.me.firstName, lastName: this.req.me.lastName, - primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : 'Vulnerability management', + 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, }); 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 b514e2bae0..38cd7d5204 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 @@ -45,6 +45,11 @@ module.exports = { fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage}) { + if(sails.config.environment !== 'production') { + sails.log.verbose('Skipping Salesforce integration...'); + return; + } + require('assert')(sails.config.custom.salesforceIntegrationUsername); require('assert')(sails.config.custom.salesforceIntegrationPasskey); require('assert')(sails.config.custom.iqSecret); From 1b261294651b1dd5739b00d33c1232082a57ea8e Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 26 Apr 2024 07:22:31 -0300 Subject: [PATCH 42/83] Delete expired activities in cleanup cron (#18441) #16989 API draft PR: #17809. UI draft PR: #18243. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [X] 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.~ - [X] Added/updated tests - ~[ ] 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.~ - ~[ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.~ - ~[ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).~ - [X] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ --- changes/16989-delete-activities | 1 + cmd/fleet/cron.go | 13 + cmd/fleetctl/gitops_test.go | 2 + .../expectedGetConfigAppConfigJson.json | 4 + .../expectedGetConfigAppConfigYaml.yml | 3 + ...ectedGetConfigIncludeServerConfigJson.json | 4 + ...pectedGetConfigIncludeServerConfigYaml.yml | 3 + .../gitops/global_config_no_paths.yml | 3 + .../macosSetupExpectedAppConfigEmpty.yml | 3 + .../macosSetupExpectedAppConfigSet.yml | 3 + pkg/spec/gitops_test.go | 18 +- pkg/spec/testdata/global_config_no_paths.yml | 3 + pkg/spec/testdata/org-settings.yml | 3 + server/datastore/mysql/activities.go | 58 +++++ server/datastore/mysql/activities_test.go | 241 ++++++++++++++++++ server/datastore/mysql/schema.sql | 2 +- server/fleet/app.go | 11 +- server/fleet/datastore.go | 5 + server/mock/datastore_mock.go | 12 + server/service/appconfig.go | 15 +- server/service/integration_core_test.go | 26 ++ .../generated_files/appconfig.txt | 3 + 22 files changed, 423 insertions(+), 13 deletions(-) create mode 100644 changes/16989-delete-activities diff --git a/changes/16989-delete-activities b/changes/16989-delete-activities new file mode 100644 index 0000000000..b90414df7e --- /dev/null +++ b/changes/16989-delete-activities @@ -0,0 +1 @@ +- Added flag to enable deletion of old activities and associated data in cleanup cron job (`activity_expiry_settings.activity_expiry_enabled` and `activity_expiry_settings.activity_expiry_window`). The cleanup cron job deletes up to 5000 expired activities on each hourly run (thus, up to ~120,000 expired activities are cleaned up a day). diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 2e4e31249b..e719be78f1 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -835,6 +835,19 @@ func newCleanupsAndAggregationSchedule( schedule.WithJob("cleanup_unused_script_contents", func(ctx context.Context) error { return ds.CleanupUnusedScriptContents(ctx) }), + schedule.WithJob("cleanup_activities", func(ctx context.Context) error { + appConfig, err := ds.AppConfig(ctx) + if err != nil { + return err + } + if !appConfig.ActivityExpirySettings.ActivityExpiryEnabled { + return nil + } + // A maxCount of 5,000 means that the cron job will keep the activities (and associated tables) + // sizes in control for deployments that generate (5k x 24 hours) ~120,000 activities per day. + const maxCount = 5000 + return ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, appConfig.ActivityExpirySettings.ActivityExpiryWindow) + }), ) return s, nil diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 4db2ff3b7e..a380e885ba 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -375,6 +375,8 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Len(t, appliedWinProfiles, 1) require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1) assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"]) + assert.True(t, savedAppConfig.ActivityExpirySettings.ActivityExpiryEnabled) + assert.Equal(t, 60, savedAppConfig.ActivityExpirySettings.ActivityExpiryWindow) } func TestFullTeamGitOps(t *testing.T) { diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 1d6d8ca1c2..6cd967ae3d 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -35,6 +35,10 @@ "host_expiry_enabled": false, "host_expiry_window": 0 }, + "activity_expiry_settings": { + "activity_expiry_enabled": false, + "activity_expiry_window": 0 + }, "features": { "enable_host_users": true, "enable_software_inventory": false diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 0b0f3044bf..707bd618f0 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -7,6 +7,9 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + activity_expiry_settings: + activity_expiry_enabled: false + activity_expiry_window: 0 features: enable_host_users: true enable_software_inventory: false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 5e83fea818..2265c7b200 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -35,6 +35,10 @@ "host_expiry_enabled": false, "host_expiry_window": 0 }, + "activity_expiry_settings": { + "activity_expiry_enabled": false, + "activity_expiry_window": 0 + }, "features": { "enable_host_users": true, "enable_software_inventory": false diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index a49a74f427..298366fc5c 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -7,6 +7,9 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + activity_expiry_settings: + activity_expiry_enabled: false + activity_expiry_window: 0 features: enable_host_users: true enable_software_inventory: false diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index b487bf46e7..d20cf84074 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -171,6 +171,9 @@ org_settings: transparency_url: https://fleetdm.com/transparency host_expiry_settings: # Applies to all teams host_expiry_enabled: false + activity_expiry_settings: + activity_expiry_enabled: true + activity_expiry_window: 60 features: # Features added to all teams enable_host_users: true enable_software_inventory: true diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index bf6a1b37f8..848e83bf17 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -10,6 +10,9 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + activity_expiry_settings: + activity_expiry_enabled: false + activity_expiry_window: 0 integrations: google_calendar: null jira: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 7d69d92259..fbe52c9190 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -10,6 +10,9 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + activity_expiry_settings: + activity_expiry_enabled: false + activity_expiry_window: 0 integrations: google_calendar: null jira: null diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 7a94695c63..98a0a36a0c 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -2,13 +2,14 @@ package spec import ( "fmt" - "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "os" "path/filepath" "slices" "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var topLevelOptions = map[string]string{ @@ -99,6 +100,7 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Contains(t, gitops.OrgSettings, "webhook_settings") assert.Contains(t, gitops.OrgSettings, "fleet_desktop") assert.Contains(t, gitops.OrgSettings, "host_expiry_settings") + assert.Contains(t, gitops.OrgSettings, "activity_expiry_settings") assert.Contains(t, gitops.OrgSettings, "features") assert.Contains(t, gitops.OrgSettings, "vulnerability_settings") assert.Contains(t, gitops.OrgSettings, "secrets") @@ -107,6 +109,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) + activityExpirySettings, ok := gitops.OrgSettings["activity_expiry_settings"].(map[string]interface{}) + require.True(t, ok) + activityExpiryEnabled, ok := activityExpirySettings["activity_expiry_enabled"].(bool) + require.True(t, ok) + require.True(t, activityExpiryEnabled) + activityExpiryWindow, ok := activityExpirySettings["activity_expiry_window"].(float64) + require.True(t, ok) + require.Equal(t, 30, int(activityExpiryWindow)) } // Check controls @@ -144,7 +154,6 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name) assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name) assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name) - }, ) } @@ -239,7 +248,6 @@ func TestMixingGlobalAndTeamConfig(t *testing.T) { config += "team_settings:\n secrets: []\n" _, err = GitOpsFromBytes([]byte(config), "") assert.ErrorContains(t, err, "'org_settings' cannot be used with 'name' or 'team_settings'") - } func TestInvalidGitOpsYaml(t *testing.T) { diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml index 4c4ee3eb7d..cdc6e78923 100644 --- a/pkg/spec/testdata/global_config_no_paths.yml +++ b/pkg/spec/testdata/global_config_no_paths.yml @@ -171,6 +171,9 @@ org_settings: transparency_url: https://fleetdm.com/transparency host_expiry_settings: # Applies to all teams host_expiry_enabled: false + activity_expiry_settings: + activity_expiry_enabled: true + activity_expiry_window: 30 features: # Features added to all teams enable_host_users: true enable_software_inventory: true diff --git a/pkg/spec/testdata/org-settings.yml b/pkg/spec/testdata/org-settings.yml index 98038b2077..17855a1b8b 100644 --- a/pkg/spec/testdata/org-settings.yml +++ b/pkg/spec/testdata/org-settings.yml @@ -74,6 +74,9 @@ fleet_desktop: # Applies to Fleet Premium only transparency_url: https://fleetdm.com/transparency host_expiry_settings: # Applies to all teams host_expiry_enabled: false +activity_expiry_settings: + activity_expiry_enabled: true + activity_expiry_window: 30 features: # Features added to all teams enable_host_users: true enable_software_inventory: true diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index b10cb86c09..3e6199dfaf 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -288,3 +288,61 @@ func (ds *Datastore) ListHostPastActivities(ctx context.Context, hostID uint, op return activities, metaData, nil } + +func (ds *Datastore) CleanupActivitiesAndAssociatedData(ctx context.Context, maxCount int, expiredWindowDays int) error { + const selectActivitiesQuery = ` + SELECT a.id FROM activities a + LEFT JOIN host_activities ha ON (a.id=ha.activity_id) + WHERE ha.activity_id IS NULL AND a.created_at < DATE_SUB(NOW(), INTERVAL ? DAY) + ORDER BY a.id ASC + LIMIT ?;` + var activityIDs []uint + if err := sqlx.SelectContext(ctx, ds.writer(ctx), &activityIDs, selectActivitiesQuery, expiredWindowDays, maxCount); err != nil { + return ctxerr.Wrap(ctx, err, "select activities for deletion") + } + if len(activityIDs) > 0 { + deleteActivitiesQuery, args, err := sqlx.In(`DELETE FROM activities WHERE id IN (?);`, activityIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build activities IN query") + } + if _, err := ds.writer(ctx).ExecContext(ctx, deleteActivitiesQuery, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired activities") + } + } + + // + // `activities` and `queries` are not tied because the activity itself holds + // the query SQL so they don't need to be executed on the same transaction. + // + if err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // Delete temporary queries (aka "not saved"). + if _, err := tx.ExecContext(ctx, + `DELETE FROM queries + WHERE NOT saved AND created_at < DATE_SUB(NOW(), INTERVAL ? DAY) + LIMIT ?`, + expiredWindowDays, maxCount, + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired non-saved queries") + } + // Delete distributed campaigns that reference unexisting query (removed in the previous query). + if _, err := tx.ExecContext(ctx, + `DELETE distributed_query_campaigns FROM distributed_query_campaigns + LEFT JOIN queries ON (distributed_query_campaigns.query_id=queries.id) + WHERE queries.id IS NULL`, + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaigns") + } + // Delete distributed campaign targets that reference unexisting distributed campaign (removed in the previous query). + if _, err := tx.ExecContext(ctx, + `DELETE distributed_query_campaign_targets FROM distributed_query_campaign_targets + LEFT JOIN distributed_query_campaigns ON (distributed_query_campaign_targets.distributed_query_campaign_id=distributed_query_campaigns.id) + WHERE distributed_query_campaigns.id IS NULL`, + ); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired orphaned distributed_query_campaign_targets") + } + return nil + }); err != nil { + return ctxerr.Wrap(ctx, err, "delete expired distributed queries") + } + return nil +} diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 8ffccc5b6e..ec4e2b8cc9 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -2,15 +2,18 @@ package mysql import ( "context" + "database/sql" "encoding/json" "fmt" "sort" + "strings" "testing" "time" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -29,6 +32,8 @@ func TestActivity(t *testing.T) { {"PaginationMetadata", testActivityPaginationMetadata}, {"ListHostUpcomingActivities", testListHostUpcomingActivities}, {"ListHostPastActivities", testListHostPastActivities}, + {"CleanupActivitiesAndAssociatedData", testCleanupActivitiesAndAssociatedData}, + {"CleanupActivitiesAndAssociatedDataBatch", testCleanupActivitiesAndAssociatedDataBatch}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -553,3 +558,239 @@ func testListHostPastActivities(t *testing.T, ds *Datastore) { } } } + +func testCleanupActivitiesAndAssociatedData(t *testing.T, ds *Datastore) { + ctx := context.Background() + user1 := &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + } + user1, err := ds.NewUser(ctx, user1) + require.NoError(t, err) + + // Nothing to delete. + err = ds.CleanupActivitiesAndAssociatedData(ctx, 500, 1) + require.NoError(t, err) + + nonSavedQuery1, err := ds.NewQuery(ctx, &fleet.Query{ + Name: "nonSavedQuery1", + Saved: false, + Query: "SELECT 1;", + Logging: fleet.LoggingSnapshot, + }) + require.NoError(t, err) + savedQuery1, err := ds.NewQuery(ctx, &fleet.Query{ + Name: "savedQuery1", + Saved: true, + Query: "SELECT 2;", + Logging: fleet.LoggingSnapshot, + }) + require.NoError(t, err) + distributedQueryCampaign1, err := ds.NewDistributedQueryCampaign(ctx, &fleet.DistributedQueryCampaign{ + QueryID: nonSavedQuery1.ID, + Status: fleet.QueryComplete, + UserID: user1.ID, + }) + require.NoError(t, err) + _, err = ds.NewDistributedQueryCampaignTarget(ctx, &fleet.DistributedQueryCampaignTarget{ + DistributedQueryCampaignID: distributedQueryCampaign1.ID, + TargetID: 1, + Type: fleet.TargetHost, + }) + require.NoError(t, err) + err = ds.NewActivity(ctx, user1, dummyActivity{ + name: "other activity", + details: map[string]interface{}{"detail": 0, "foo": "zoo"}, + }) + require.NoError(t, err) + err = ds.NewActivity(ctx, user1, dummyActivity{ + name: "live query", + details: map[string]interface{}{"detail": 1, "foo": "bar"}, + }) + require.NoError(t, err) + err = ds.NewActivity(ctx, user1, dummyActivity{ + name: "some host activity", + details: map[string]interface{}{"detail": 0, "foo": "zoo"}, + hostIDs: []uint{1}, + }) + require.NoError(t, err) + err = ds.NewActivity(ctx, user1, dummyActivity{ + name: "some host activity 2", + details: map[string]interface{}{"detail": 0, "foo": "bar"}, + hostIDs: []uint{2}, + }) + require.NoError(t, err) + + // Nothing is deleted, as the activities and associated data is recent. + const maxCount = 500 + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err := ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 4) + nonExpiredActivityID := activities[0].ID + expiredActivityID := activities[1].ID + nonExpiredHostActivityID := activities[2].ID + expiredHostActivityID := activities[3].ID + _, err = ds.Query(ctx, nonSavedQuery1.ID) + require.NoError(t, err) + _, err = ds.DistributedQueryCampaign(ctx, distributedQueryCampaign1.ID) + require.NoError(t, err) + targets, err := ds.DistributedQueryCampaignTargetIDs(ctx, distributedQueryCampaign1.ID) + require.NoError(t, err) + require.Len(t, targets.HostIDs, 1) + + // Make some of the activity and associated data older. + _, err = ds.writer(context.Background()).Exec(` + UPDATE activities SET created_at = ? WHERE id = ? OR id = ?`, + time.Now().Add(-48*time.Hour), expiredActivityID, expiredHostActivityID, + ) + require.NoError(t, err) + _, err = ds.writer(context.Background()).Exec(` + UPDATE queries SET created_at = ? WHERE id = ? OR id = ?`, + time.Now().Add(-48*time.Hour), nonSavedQuery1.ID, savedQuery1.ID, + ) + require.NoError(t, err) + + // Expired activity and associated data should be cleaned up. + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err = ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 3) + require.Equal(t, nonExpiredActivityID, activities[0].ID) + require.Equal(t, nonExpiredHostActivityID, activities[1].ID) + require.Equal(t, expiredHostActivityID, activities[2].ID) + _, err = ds.Query(ctx, nonSavedQuery1.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + _, err = ds.DistributedQueryCampaign(ctx, distributedQueryCampaign1.ID) + require.ErrorIs(t, err, sql.ErrNoRows) + targets, err = ds.DistributedQueryCampaignTargetIDs(ctx, distributedQueryCampaign1.ID) + require.NoError(t, err) + require.Empty(t, targets.HostIDs) + require.Empty(t, targets.LabelIDs) + require.Empty(t, targets.TeamIDs) + + // Saved query should not be cleaned up. + savedQuery1, err = ds.Query(ctx, savedQuery1.ID) + require.NoError(t, err) + require.NotNil(t, savedQuery1) +} + +func testCleanupActivitiesAndAssociatedDataBatch(t *testing.T, ds *Datastore) { + ctx := context.Background() + user1 := &fleet.User{ + Password: []byte("p4ssw0rd.123"), + Name: "user1", + Email: "user1@example.com", + GlobalRole: ptr.String(fleet.RoleAdmin), + } + user1, err := ds.NewUser(ctx, user1) + require.NoError(t, err) + + const maxCount = 500 + + // Create 1500 activities. + insertActivitiesStmt := ` + INSERT INTO activities + (user_id, user_name, activity_type, details, user_email) + VALUES ` + var insertActivitiesArgs []interface{} + for i := 0; i < 1500; i++ { + insertActivitiesArgs = append(insertActivitiesArgs, + user1.ID, user1.Name, "foobar", `{"foo": "bar"}`, user1.Email, + ) + } + insertActivitiesStmt += strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?, ?),", 1500), ",") + _, err = ds.writer(ctx).ExecContext(ctx, insertActivitiesStmt, insertActivitiesArgs...) + require.NoError(t, err) + + // Create 1500 non-saved queries. + insertQueriesStmt := ` + INSERT INTO queries + (name, description, query) + VALUES ` + var insertQueriesArgs []interface{} + for i := 0; i < 1500; i++ { + insertQueriesArgs = append(insertQueriesArgs, + fmt.Sprintf("foobar%d", i), "foobar", "SELECT 1;", + ) + } + insertQueriesStmt += strings.TrimSuffix(strings.Repeat("(?, ?, ?),", 1500), ",") + _, err = ds.writer(ctx).ExecContext(ctx, insertQueriesStmt, insertQueriesArgs...) + require.NoError(t, err) + + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err := ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 1500) + var queriesLen int + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) + }) + require.Equal(t, 1500, queriesLen) + + // Make 1250 activities as expired. + _, err = ds.writer(context.Background()).Exec(` + UPDATE activities SET created_at = ? WHERE id <= 1250`, + time.Now().Add(-48*time.Hour), + ) + require.NoError(t, err) + + // Make 1250 queries as expired. + _, err = ds.writer(context.Background()).Exec(` + UPDATE queries SET created_at = ? WHERE id <= 1250`, + time.Now().Add(-48*time.Hour), + ) + require.NoError(t, err) + + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err = ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 1000) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) + }) + require.Equal(t, 1000, queriesLen) + + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err = ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 500) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) + }) + require.Equal(t, 500, queriesLen) + + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err = ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 250) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) + }) + require.Equal(t, 250, queriesLen) + + err = ds.CleanupActivitiesAndAssociatedData(ctx, maxCount, 1) + require.NoError(t, err) + + activities, _, err = ds.ListActivities(ctx, fleet.ListActivitiesOptions{}) + require.NoError(t, err) + require.Len(t, activities, 250) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`) + }) + require.Equal(t, 250, queriesLen) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 19df87045d..8dabad4396 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) 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\": {\"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}, \"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, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"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\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"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}, \"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, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"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 */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `calendar_events` ( diff --git a/server/fleet/app.go b/server/fleet/app.go index ac056347f0..8f4525a595 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -434,8 +434,9 @@ type AppConfig struct { // SMTPSettings holds the SMTP integration settings. // // This field is a pointer to avoid returning this information to non-global-admins. - SMTPSettings *SMTPSettings `json:"smtp_settings,omitempty"` - HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` + SMTPSettings *SMTPSettings `json:"smtp_settings,omitempty"` + HostExpirySettings HostExpirySettings `json:"host_expiry_settings"` + ActivityExpirySettings ActivityExpirySettings `json:"activity_expiry_settings"` // Features allows to globally enable or disable features Features Features `json:"features"` DeprecatedHostSettings *Features `json:"host_settings,omitempty"` @@ -888,6 +889,12 @@ type HostExpirySettings struct { HostExpiryWindow int `json:"host_expiry_window"` } +// ActivityExpirySettings contains settings pertaining to automatic activities cleanup. +type ActivityExpirySettings struct { + ActivityExpiryEnabled bool `json:"activity_expiry_enabled"` + ActivityExpiryWindow int `json:"activity_expiry_window"` +} + type Features struct { EnableHostUsers bool `json:"enable_host_users"` EnableSoftwareInventory bool `json:"enable_software_inventory"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 97f66e6c7f..764552802c 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1430,6 +1430,11 @@ type Datastore interface { // CleanupUnusedScriptContents will remove script contents that have no references to them from // the scripts or host_script_results tables. CleanupUnusedScriptContents(ctx context.Context) error + // CleanupActivitiesAndAssociatedData will cleanup (up to maxCount) activities and their associated data + // that are older than the given expiration window. + // + // The argument maxCount is used to not lock the database for long periods of time. + CleanupActivitiesAndAssociatedData(ctx context.Context, maxCount int, expiryWindowDays int) error // WipeHostViaScript sends a script to wipe a host and updates the // states in host_mdm_actions. WipeHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index ca698db3a7..a1211c4f74 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -911,6 +911,8 @@ type CleanMacOSMDMLockFunc func(ctx context.Context, hostUUID string) error type CleanupUnusedScriptContentsFunc func(ctx context.Context) error +type CleanupActivitiesAndAssociatedDataFunc func(ctx context.Context, maxCount int, expiryWindowDays int) error + type WipeHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error @@ -2256,6 +2258,9 @@ type DataStore struct { CleanupUnusedScriptContentsFunc CleanupUnusedScriptContentsFunc CleanupUnusedScriptContentsFuncInvoked bool + CleanupActivitiesAndAssociatedDataFunc CleanupActivitiesAndAssociatedDataFunc + CleanupActivitiesAndAssociatedDataFuncInvoked bool + WipeHostViaScriptFunc WipeHostViaScriptFunc WipeHostViaScriptFuncInvoked bool @@ -5390,6 +5395,13 @@ func (s *DataStore) CleanupUnusedScriptContents(ctx context.Context) error { return s.CleanupUnusedScriptContentsFunc(ctx) } +func (s *DataStore) CleanupActivitiesAndAssociatedData(ctx context.Context, maxCount int, expiryWindowDays int) error { + s.mu.Lock() + s.CleanupActivitiesAndAssociatedDataFuncInvoked = true + s.mu.Unlock() + return s.CleanupActivitiesAndAssociatedDataFunc(ctx, maxCount, expiryWindowDays) +} + func (s *DataStore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { s.mu.Lock() s.WipeHostViaScriptFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 4866475b7a..b2b0c0be12 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -150,11 +150,12 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se features := appConfig.Features response := appConfigResponse{ AppConfig: fleet.AppConfig{ - OrgInfo: appConfig.OrgInfo, - ServerSettings: appConfig.ServerSettings, - Features: features, - VulnerabilitySettings: appConfig.VulnerabilitySettings, - HostExpirySettings: appConfig.HostExpirySettings, + OrgInfo: appConfig.OrgInfo, + ServerSettings: appConfig.ServerSettings, + Features: features, + VulnerabilitySettings: appConfig.VulnerabilitySettings, + HostExpirySettings: appConfig.HostExpirySettings, + ActivityExpirySettings: appConfig.ActivityExpirySettings, SMTPSettings: smtpSettings, SSOSettings: ssoSettings, @@ -376,6 +377,10 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle invalid.Append("server_url", "Fleet server URL must be present") } + if appConfig.ActivityExpirySettings.ActivityExpiryEnabled && appConfig.ActivityExpirySettings.ActivityExpiryWindow < 1 { + invalid.Append("activity_expiry_settings.activity_expiry_window", "must be greater than 0") + } + if appConfig.OrgInfo.ContactURL == "" { appConfig.OrgInfo.ContactURL = fleet.DefaultOrgInfoContactURL } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 0d5f371e44..88742e44df 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6115,6 +6115,8 @@ func (s *integrationTestSuite) TestAppConfig() { assert.Equal(t, "free", acResp.License.Tier) assert.Equal(t, "FleetTest", acResp.OrgInfo.OrgName) // set in SetupSuite assert.False(t, acResp.MDM.AppleBMTermsExpired) + assert.False(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled) + assert.Zero(t, acResp.ActivityExpirySettings.ActivityExpiryWindow) // set the apple BM terms expired flag, and the enabled and configured flags, // we'll check again at the end of this test to make sure they weren't @@ -6157,6 +6159,30 @@ func (s *integrationTestSuite) TestAppConfig() { s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) require.Contains(t, string(*acResp.AgentOptions), `"logger_plugin": "tls"`) // default agent options has this setting + // Invalid activity expiry window. + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "activity_expiry_settings": { + "activity_expiry_enabled": true, + "activity_expiry_window": -1 + } + }`), http.StatusUnprocessableEntity, &acResp) + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.False(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled) + require.Zero(t, acResp.ActivityExpirySettings.ActivityExpiryWindow) + + // Valid activity expiry window. + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "activity_expiry_settings": { + "activity_expiry_enabled": true, + "activity_expiry_window": 42 + } + }`), http.StatusOK, &acResp) + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.True(t, acResp.ActivityExpirySettings.ActivityExpiryEnabled) + require.Equal(t, 42, acResp.ActivityExpirySettings.ActivityExpiryWindow) + // test a change that does clear the agent options (the field is provided but empty). s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "agent_options": {} diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 7fc10249d5..f3b6c8df0f 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -28,6 +28,9 @@ github.com/fleetdm/fleet/v4/server/fleet/SMTPSettings SMTPEnableStartTLS bool github.com/fleetdm/fleet/v4/server/fleet/AppConfig HostExpirySettings fleet.HostExpirySettings github.com/fleetdm/fleet/v4/server/fleet/HostExpirySettings HostExpiryEnabled bool github.com/fleetdm/fleet/v4/server/fleet/HostExpirySettings HostExpiryWindow int +github.com/fleetdm/fleet/v4/server/fleet/AppConfig ActivityExpirySettings fleet.ActivityExpirySettings +github.com/fleetdm/fleet/v4/server/fleet/ActivityExpirySettings ActivityExpiryEnabled bool +github.com/fleetdm/fleet/v4/server/fleet/ActivityExpirySettings ActivityExpiryWindow int github.com/fleetdm/fleet/v4/server/fleet/AppConfig Features fleet.Features github.com/fleetdm/fleet/v4/server/fleet/Features EnableHostUsers bool github.com/fleetdm/fleet/v4/server/fleet/Features EnableSoftwareInventory bool From a6c09cb5f4626c450f0fcedaaa6510c62638f34f Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 26 Apr 2024 09:13:46 -0500 Subject: [PATCH 43/83] Website: Update update-or-create-contact-and-account helper (#18565) Changes: - Updated the `salesforce/update-or-create-contact-and-account` helper to not to try to use `enrichementData.employer.emailDomain` if an `organization` was provided. --- .../helpers/salesforce/update-or-create-contact-and-account.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38cd7d5204..016e1ad32e 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 @@ -73,7 +73,7 @@ module.exports = { await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); let salesforceAccountId; - if(!organization && !enrichmentData.employer){ + if(!enrichmentData.employer || !enrichmentData.employer.emailDomain) { // Special sacraficial meat cave where the contacts with no organization go. // https://fleetdm.lightning.force.com/lightning/r/Account/0014x000025JC8DAAW/view salesforceAccountId = '0014x000025JC8DAAW'; From 08b5e1c7b5853deffd6e1237e3c106b1015a15a0 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Fri, 26 Apr 2024 12:09:01 -0300 Subject: [PATCH 44/83] fixed a bug causing servers with Windows MDM only to crash (#18568) --- changes/18558-windows-mdm-start | 1 + cmd/fleet/cron.go | 11 +------- cmd/fleet/cron_test.go | 26 +++++++++++++++++++ server/service/apple_mdm.go | 20 +++++++++++++- server/service/apple_mdm_test.go | 8 +++--- .../service/integration_mdm_profiles_test.go | 6 ++--- server/service/integration_mdm_test.go | 4 +-- 7 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 changes/18558-windows-mdm-start create mode 100644 cmd/fleet/cron_test.go diff --git a/changes/18558-windows-mdm-start b/changes/18558-windows-mdm-start new file mode 100644 index 0000000000..13e96aab57 --- /dev/null +++ b/changes/18558-windows-mdm-start @@ -0,0 +1 @@ +* Fixed a bug that prevented the Fleet server to start if Windows MDM was configured but Apple MDM wasn't diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index e719be78f1..824d09e515 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -1037,21 +1037,12 @@ func newMDMProfileManager( defaultInterval = 30 * time.Second ) - if !cfg.IsAppleSCEPSet() { - return nil, ctxerr.New(ctx, "SCEP configuration is required") - } - - cert, _, _, err := cfg.AppleSCEP() - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting Apple SCEP keypair") - } - logger = kitlog.With(logger, "cron", name) s := schedule.New( ctx, name, instanceID, defaultInterval, ds, ds, schedule.WithLogger(logger), schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error { - return service.ReconcileAppleProfiles(ctx, ds, commander, logger, cert) + return service.ReconcileAppleProfiles(ctx, ds, commander, logger, cfg) }), schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error { return service.ReconcileAppleDeclarations(ctx, ds, commander, logger) diff --git a/cmd/fleet/cron_test.go b/cmd/fleet/cron_test.go new file mode 100644 index 0000000000..0cc113cafc --- /dev/null +++ b/cmd/fleet/cron_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fleetdm/fleet/v4/server/config" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mock" + kitlog "github.com/go-kit/log" +) + +func TestNewMDMProfileManagerWithoutConfig(t *testing.T) { + ctx := context.Background() + mdmStorage := &mock.MDMAppleStore{} + ds := new(mock.Store) + mdmConfig := config.MDMConfig{} + cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, nil, mdmConfig) + logger := kitlog.NewNopLogger() + + sch, err := newMDMProfileManager(ctx, "foo", ds, cmdr, logger, false, mdmConfig) + require.NotNil(t, sch) + require.NoError(t, err) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 21fb5551d5..b84d1888db 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2812,6 +2812,14 @@ func ReconcileAppleDeclarations( commander *apple_mdm.MDMAppleCommander, logger kitlog.Logger, ) error { + appConfig, err := ds.AppConfig(ctx) + if err != nil { + return fmt.Errorf("reading app config: %w", err) + } + if !appConfig.MDM.EnabledAndConfigured { + return nil + } + // batch set declarations as pending changedHosts, err := ds.MDMAppleBatchSetHostDeclarationState(ctx) if err != nil { @@ -2838,7 +2846,7 @@ func ReconcileAppleProfiles( ds fleet.Datastore, commander *apple_mdm.MDMAppleCommander, logger kitlog.Logger, - signingCert *tls.Certificate, + cfg config.MDMConfig, ) error { appConfig, err := ds.AppConfig(ctx) if err != nil { @@ -2847,6 +2855,16 @@ func ReconcileAppleProfiles( if !appConfig.MDM.EnabledAndConfigured { return nil } + + if !cfg.IsAppleSCEPSet() { + return ctxerr.New(ctx, "SCEP configuration is required") + } + + signingCert, _, _, err := cfg.AppleSCEP() + if err != nil { + return ctxerr.Wrap(ctx, err, "getting Apple SCEP keypair") + } + if err := ensureFleetProfiles(ctx, ds, logger, signingCert); err != nil { logger.Log("err", "unable to ensure a fleetd configuration profiles are in place", "details", err) } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 07d12c9749..db4650a1d5 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -2104,8 +2104,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { contents1 := []byte("test-content-1") contents2 := []byte("test-content-2") contents4 := []byte("test-content-4") - signingCert, _, _, err := mdmConfig.AppleSCEP() - require.NoError(t, err) p1, p2, p3, p4 := "a"+uuid.NewString(), "a"+uuid.NewString(), "a"+uuid.NewString(), "a"+uuid.NewString() ds.ListMDMAppleProfilesToInstallFunc = func(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) { @@ -2318,7 +2316,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { failedCount++ require.Len(t, payload, 0) } - err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert) + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), mdmConfig) require.NoError(t, err) require.Equal(t, 1, failedCount) checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) @@ -2354,7 +2352,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { } enqueueFailForOp = fleet.MDMOperationTypeRemove - err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert) + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), mdmConfig) require.NoError(t, err) require.Equal(t, 1, failedCount) checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) @@ -2407,7 +2405,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { } enqueueFailForOp = fleet.MDMOperationTypeInstall - err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert) + err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), mdmConfig) require.NoError(t, err) require.Equal(t, 1, failedCount) checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked) diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 0b10dbeb4c..897fcce051 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -4001,9 +4001,7 @@ func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() { if len(secrets) == 0 { require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})) } - signingCert, _, _, err := s.fleetCfg.MDM.AppleSCEP() - require.NoError(t, err) - require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, signingCert)) + require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, s.fleetCfg.MDM)) // turn on disk encryption and os updates s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -4081,7 +4079,7 @@ func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() { require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) require.Equal(t, "13.3.8", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) - require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, signingCert)) + require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, s.fleetCfg.MDM)) checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...) checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 90cb5fad04..4510cfc2e6 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -212,9 +212,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { if s.onProfileJobDone != nil { s.onProfileJobDone() } - signingCert, _, _, err := fleetCfg.MDM.AppleSCEP() - require.NoError(s.T(), err) - err = ReconcileAppleProfiles(ctx, ds, mdmCommander, logger, signingCert) + err = ReconcileAppleProfiles(ctx, ds, mdmCommander, logger, fleetCfg.MDM) require.NoError(s.T(), err) return err }), From b75545ba26c6788fa6061a8c635b40926a817e2e Mon Sep 17 00:00:00 2001 From: George Karr Date: Fri, 26 Apr 2024 12:35:44 -0500 Subject: [PATCH 45/83] Adding changes for patch 4.49.1 (#18573) --- CHANGELOG.md | 6 ++++++ charts/fleet/Chart.yaml | 2 +- charts/fleet/values.yaml | 2 +- infrastructure/dogfood/terraform/aws/variables.tf | 2 +- infrastructure/dogfood/terraform/gcp/variables.tf | 2 +- terraform/README.md | 2 +- terraform/byo-vpc/README.md | 2 +- terraform/byo-vpc/byo-db/byo-ecs/variables.tf | 2 +- terraform/byo-vpc/byo-db/variables.tf | 2 +- terraform/byo-vpc/example/main.tf | 2 +- terraform/byo-vpc/variables.tf | 2 +- terraform/example/main.tf | 4 ++-- terraform/variables.tf | 2 +- tools/fleetctl-npm/package.json | 2 +- 14 files changed, 20 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2989ae97ab..2d5d86e114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Fleet 4.49.1 (Apr 26, 2024) + +### Bug fixes + +* Fixed a bug that prevented the Fleet server from starting if Windows MDM was configured but Apple MDM wasn't. + ## Fleet 4.49.0 (Apr 24, 2024) ### Endpoint operations diff --git a/charts/fleet/Chart.yaml b/charts/fleet/Chart.yaml index 6ccfba23e6..f810d5a1f8 100644 --- a/charts/fleet/Chart.yaml +++ b/charts/fleet/Chart.yaml @@ -8,7 +8,7 @@ version: v6.0.2 home: https://github.com/fleetdm/fleet sources: - https://github.com/fleetdm/fleet.git -appVersion: v4.49.0 +appVersion: v4.49.1 dependencies: - name: mysql condition: mysql.enabled diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index ee3ec70068..efbb7fa256 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -2,7 +2,7 @@ # All settings related to how Fleet is deployed in Kubernetes hostName: fleet.localhost replicas: 3 # The number of Fleet instances to deploy -imageTag: v4.49.0 # Version of Fleet to deploy +imageTag: v4.49.1 # Version of Fleet to deploy podAnnotations: {} # Additional annotations to add to the Fleet pod serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account resources: diff --git a/infrastructure/dogfood/terraform/aws/variables.tf b/infrastructure/dogfood/terraform/aws/variables.tf index 833f16bf3a..83f3879dac 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.49.0" + default = "fleetdm/fleet:v4.49.1" } variable "software_inventory" { diff --git a/infrastructure/dogfood/terraform/gcp/variables.tf b/infrastructure/dogfood/terraform/gcp/variables.tf index 2e600c65a7..7de4a7f02f 100644 --- a/infrastructure/dogfood/terraform/gcp/variables.tf +++ b/infrastructure/dogfood/terraform/gcp/variables.tf @@ -68,5 +68,5 @@ variable "redis_mem" { } variable "image" { - default = "fleet:v4.49.0" + default = "fleet:v4.49.1" } diff --git a/terraform/README.md b/terraform/README.md index 5c5a8cc103..cb1fd829a1 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -75,7 +75,7 @@ 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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.0")
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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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 | | [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 | diff --git a/terraform/byo-vpc/README.md b/terraform/byo-vpc/README.md index bd18a989e2..89aa013d1f 100644 --- a/terraform/byo-vpc/README.md +++ b/terraform/byo-vpc/README.md @@ -34,7 +34,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 | <<<<<<< HEAD -| [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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.0")
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)
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.49.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)
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"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({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.48.0")
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, "")
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 = list(string)
security_groups = optional(list(string), null)
}), {
subnets = null
security_groups = null
})
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"
})
})
|
{
"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.31.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"security_groups": null,
"subnets": null
},
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"volumes": []
}
| no | >>>>>>> 025004bcf (support private registry in the ecs task definition) diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf index e1b4e0e9c1..3e9bd4507a 100644 --- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf +++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf @@ -13,7 +13,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.49.0") + image = optional(string, "fleetdm/fleet:v4.49.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf index b3f21d59b1..194a11aef7 100644 --- a/terraform/byo-vpc/byo-db/variables.tf +++ b/terraform/byo-vpc/byo-db/variables.tf @@ -74,7 +74,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.49.0") + image = optional(string, "fleetdm/fleet:v4.49.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf index f4b0598e6e..aae37cc107 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.49.0" + fleet_image = "fleetdm/fleet:v4.49.1" domain_name = "example.com" } diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf index 1d57358551..e9463dda11 100644 --- a/terraform/byo-vpc/variables.tf +++ b/terraform/byo-vpc/variables.tf @@ -167,7 +167,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.49.0") + image = optional(string, "fleetdm/fleet:v4.49.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/terraform/example/main.tf b/terraform/example/main.tf index f272c8958d..4a245a7969 100644 --- a/terraform/example/main.tf +++ b/terraform/example/main.tf @@ -59,8 +59,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.49.0" - image = "fleetdm/fleet:v4.49.0" # override default to deploy the image you desire + # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.49.1" + image = "fleetdm/fleet:v4.49.1" # override default to deploy the image you desire # See https://fleetdm.com/docs/deploy/reference-architectures#aws for appropriate scaling # memory and cpu. autoscaling = { diff --git a/terraform/variables.tf b/terraform/variables.tf index ee42f916a3..d12ca9e1c7 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -215,7 +215,7 @@ variable "fleet_config" { type = object({ mem = optional(number, 4096) cpu = optional(number, 512) - image = optional(string, "fleetdm/fleet:v4.49.0") + image = optional(string, "fleetdm/fleet:v4.49.1") family = optional(string, "fleet") sidecars = optional(list(any), []) depends_on = optional(list(any), []) diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json index 27b188c7c5..e14b40f2c6 100644 --- a/tools/fleetctl-npm/package.json +++ b/tools/fleetctl-npm/package.json @@ -1,6 +1,6 @@ { "name": "fleetctl", - "version": "v4.49.0", + "version": "v4.49.1", "description": "Installer for the fleetctl CLI tool", "bin": { "fleetctl": "./run.js" From d74f5cd6ea696d4a676efe6cd898373b0c224d29 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 26 Apr 2024 12:42:48 -0500 Subject: [PATCH 46/83] Website: Update psychologicalStage changes in save-questionnaire-progress.js (#18498) Closes: https://github.com/fleetdm/confidential/issues/6215 Changes: - Updated how psychological stages are set when a user completes a step of the get started questionnaire > Note: I'm creating this as a draft PR until I review these changes to make sure this is what we want to do. --- .../save-questionnaire-progress.js | 87 ++++++++++++++----- 1 file changed, 65 insertions(+), 22 deletions(-) diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index 3c25b159c3..9be1a061ce 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -80,30 +80,32 @@ module.exports = { // - yes-deployed-local: » Stage 3 (Tried Fleet but might not have a use case) // - yes-deployed-long-time: Stage 2 (Tried Fleet long ago but might not fully grasp) // - no: Stage 2 (Never tried Fleet and might not fully grasp) - // 'how-many-hosts': No change // TODO (see above -- instead of no change there should be a change) - // 'will-you-be-self-hosting': No change // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // 'how-many-hosts': Stage 6 + // 'will-you-be-self-hosting': Stage 6 // 'what-are-you-working-on-eo-security' - // - no-use-case-yet: » No change // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // - 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: » No change // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // - 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: » No change // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // - 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: » No change // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // - no-use-case-yet: » Stage 2/3 (depends on answer from 'have-you-ever-used-fleet' step) // - All other options » Stage 4 - // 'is-it-any-good': // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // 'is-it-any-good': Stage 2/3/4 (depends on answer from 'have-you-ever-used-fleet' & the buying situation specific step) // 'what-did-you-think' // - deploy-fleet-in-environment » Stage 5 - // - let-me-think-about-it » // TODO (see above -- instead of no change there should maybe be a change, sometimes) + // - let-me-think-about-it » Stage 2 // - host-fleet-for-me » N/A (currently not selectable, but should set the user's psychologicalStage to stage 5) let psychologicalStage = userRecord.psychologicalStage; // Get the value of the submitted formData, we do this so we only need to check one variable, instead of (formData.attribute === 'foo'); let valueFromFormData = _.values(formData)[0]; - if(currentStep === 'what-are-you-using-fleet-for') { + if(currentStep === 'start') { + // There is change when the user completes the start step. + } else if(currentStep === 'what-are-you-using-fleet-for') { psychologicalStage = '2 - Aware'; } else if(currentStep === 'have-you-ever-used-fleet') { if(['yes-deployed', 'yes-recently-deployed'].includes(valueFromFormData)) { @@ -116,19 +118,60 @@ module.exports = { // Otherwise, we'll just assume liu're only aware. Maybe liu don't fully grasp what Fleet can do. psychologicalStage = '2 - Aware'; } - } else 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') { - // If this user doe not have a use case for Fleet yet, set their psyStage to 3 - psychologicalStage = '3 - Intrigued'; - } else {// Otherwise, they have a use case and will be set to stage 4. - psychologicalStage = '4 - Has use case'; - } - } else if(currentStep === 'what-did-you-think') { - // If the user is ready to deploy Fleet in their work environemnt, then they're ready to get buy-in from their team, so set their psyStage to 5. - if(valueFromFormData === 'deploy-fleet-in-environment') { - psychologicalStage = '5 - Personally confident'; - } - // If the user selects let me think about it, their stage will not change. + } else { + // If the user submitted any other step, we'll set variables using the answers to the previous questions. + // Get the user's selected primaryBuyingSiutation. + let currentSelectedBuyingSituation = questionnaireProgress['what-are-you-using-fleet-for'].primaryBuyingSituation; + // Get the user's answer to the "Have you ever used Fleet?" question. + 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'; + } + } else {// Otherwise, they have a use case and will be set to stage 4. + psychologicalStage = '4 - Has use case'; + } + } else if(currentStep === 'is-it-any-good') { + if(currentSelectedBuyingSituation === 'mdm') { + // Since the mdm use case question is the only buying situation-sepcific question where a use case can't + // be selected, we'll check the user's previous answers befroe 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'; + } + } else { + psychologicalStage = '4 - Has use case'; + } + } else {// For any other selected primary buying situation, since a use case will have been selected, set their psyStage to 4 + psychologicalStage = '4 - Has use case'; + // FUTURE: check previous answers for other selected buying situations. + } + } else if(currentStep === 'what-did-you-think') { + // If the user is ready to deploy Fleet in their work environemnt, then they're ready to get buy-in from their team, so set their psyStage to 5. + if(valueFromFormData === 'deploy-fleet-in-environment') { + psychologicalStage = '5 - Personally confident'; + } else if(valueFromFormData === 'let-me-think-about-it') { + // If the user selects "Let me think about it", their stage change to 2 + psychologicalStage = '2 - Aware'; + } + // If the user selects "I’d like you to host Fleet for me", the form is not submitted, and they are taken to the /contact page instead. FUTURE: set stage to stage 5. + } else if(currentStep === 'how-many-hosts') { + // If they have Fleet deployed, they have team buy-in + psychologicalStage = '6 - Has team buy-in'; + } else if(currentStep === 'will-you-be-self-hosting') { + // If they have Fleet deployed, they have team buy-in + psychologicalStage = '6 - Has team buy-in'; + }//fi + }//fi From 9ff682e0fffd9810ecfb58d6bd53d92f867d8de7 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Fri, 26 Apr 2024 12:46:23 -0500 Subject: [PATCH 47/83] Windows orbit.exe and fleet-desktop.exe are now signed. (#18201) #17187 Windows orbit.exe and fleet-desktop.exe are now signed. Signed fleet-desktop.exe artifact at: https://github.com/fleetdm/fleet/actions/runs/8834788809 Signed orbit.exe artifact at: https://github.com/fleetdm/fleet/actions/runs/8834817940 For signing fleetctl.exe, opened a new issue: https://github.com/fleetdm/fleet/issues/18540 --- .github/workflows/code-sign-windows.yml | 97 +++++++++++++++++++ .../workflows/generate-desktop-targets.yml | 21 +++- .github/workflows/goreleaser-orbit.yaml | 23 ++++- orbit/changes/17187-sign-windows | 1 + 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/code-sign-windows.yml create mode 100644 orbit/changes/17187-sign-windows diff --git a/.github/workflows/code-sign-windows.yml b/.github/workflows/code-sign-windows.yml new file mode 100644 index 0000000000..41a9666677 --- /dev/null +++ b/.github/workflows/code-sign-windows.yml @@ -0,0 +1,97 @@ +name: Code sign Windows binaries with DigiCert KeyLocker KSP + +on: + workflow_call: + inputs: + filename: + description: 'The name of the file to sign' + required: true + type: string + download_name: + description: 'The name of the artifact to download' + required: false + default: 'unsigned-windows' + type: string + upload_name: + description: 'The name of the artifact to upload' + required: false + default: 'signed-windows' + type: string + secrets: + DIGICERT_KEYLOCKER_CERTIFICATE: + required: true + DIGICERT_KEYLOCKER_PASSWORD: + required: true + DIGICERT_KEYLOCKER_HOST_URL: + required: true + DIGICERT_API_KEY: + required: true + DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT: + required: true + +permissions: + contents: read + +jobs: + code-sign-windows: + runs-on: windows-2022 + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Download unsigned artifact + uses: actions/download-artifact@9c19ed7fe5d278cd354c7dfd5d3b88589c7e2395 # v4.1.6 + with: + name: ${{ inputs.download_name }} + + - name: Setup certificate + run: | + echo "${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE }}" | base64 --decode > /d/Certificate_pkcs12.p12 + openssl pkcs12 -in /d/Certificate_pkcs12.p12 -nodes -passin pass:${{ secrets.DIGICERT_KEYLOCKER_PASSWORD }} | openssl x509 -noout -subject + shell: bash + + - name: Set variables + id: variables + run: | + echo "SM_HOST=${{ secrets.DIGICERT_KEYLOCKER_HOST_URL }}" >> "$GITHUB_ENV" + echo "SM_API_KEY=${{ secrets.DIGICERT_API_KEY }}" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" + echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.DIGICERT_KEYLOCKER_PASSWORD }}" >> "$GITHUB_ENV" + echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH + echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH + echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH + shell: bash + + - name: Download Keylocker KSP on windows + run: | + curl https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" --fail-with-body -o Keylockertools-windows-x64.msi + shell: cmd + + - name: Install Keylocker KSP on windows + run: | + msiexec /i Keylockertools-windows-x64.msi /quiet /qn + smksp_registrar.exe list + smctl.exe keypair ls + C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user + shell: cmd + + - name: Certificates Sync + run: | + smctl windows certsync + shell: cmd + + - name: Sign using Windows signtool + # Debug logs are at: %USERPROFILE%\.signingmanager\logs\smksp.log + run: | + signtool.exe sign /v /debug /sha1 ${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT }} /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ${{ inputs.filename }} + copy unsigned.exe signed.exe + signtool.exe verify /v /pa ${{ inputs.filename }} + shell: cmd + + - name: Upload signed artifact + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 + with: + name: ${{ inputs.upload_name }} + path: ${{ inputs.filename }} diff --git a/.github/workflows/generate-desktop-targets.yml b/.github/workflows/generate-desktop-targets.yml index 1840d48b90..5c03cc3f1f 100644 --- a/.github/workflows/generate-desktop-targets.yml +++ b/.github/workflows/generate-desktop-targets.yml @@ -84,7 +84,7 @@ jobs: make desktop-app-tar-gz - name: Upload desktop.app.tar.gz - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: name: desktop.app.tar.gz path: desktop.app.tar.gz @@ -112,11 +112,24 @@ jobs: make desktop-windows - name: Upload fleet-desktop.exe - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: - name: fleet-desktop.exe + name: unsigned-windows path: fleet-desktop.exe + code-sign-windows: + needs: desktop-windows + uses: ./.github/workflows/code-sign-windows.yml + with: + filename: fleet-desktop.exe + upload_name: fleet-desktop.exe + secrets: + DIGICERT_KEYLOCKER_CERTIFICATE: ${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE }} + DIGICERT_KEYLOCKER_PASSWORD: ${{ secrets.DIGICERT_KEYLOCKER_PASSWORD }} + DIGICERT_KEYLOCKER_HOST_URL: ${{ secrets.DIGICERT_KEYLOCKER_HOST_URL }} + DIGICERT_API_KEY: ${{ secrets.DIGICERT_API_KEY }} + DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT: ${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT }} + desktop-linux: runs-on: ubuntu-latest steps: @@ -140,7 +153,7 @@ jobs: make desktop-linux - name: Upload desktop.tar.gz - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: name: desktop.tar.gz path: desktop.tar.gz diff --git a/.github/workflows/goreleaser-orbit.yaml b/.github/workflows/goreleaser-orbit.yaml index 2f1eb3905b..1ce2386dd9 100644 --- a/.github/workflows/goreleaser-orbit.yaml +++ b/.github/workflows/goreleaser-orbit.yaml @@ -3,7 +3,7 @@ name: GoReleaser Orbit on: push: tags: - - 'orbit-*' + - '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: @@ -68,7 +68,7 @@ jobs: CODESIGN_IDENTITY: 51049B247B25B3119FAE7E9C0CC4375A43E47237 - name: Upload - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: name: orbit-macos path: dist/orbit-macos_darwin_all/orbit @@ -101,7 +101,7 @@ jobs: run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-linux.yml # v1.20.0 - name: Upload - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: name: orbit-linux path: dist/orbit_linux_amd64_v1/orbit @@ -134,7 +134,20 @@ jobs: run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-windows.yml # v1.20.0 - name: Upload - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # 4.3.3 with: - name: orbit-windows + name: unsigned-windows path: dist/orbit_windows_amd64_v1/orbit.exe + + code-sign-windows: + needs: goreleaser-windows + uses: ./.github/workflows/code-sign-windows.yml + with: + filename: orbit.exe + upload_name: orbit-windows + secrets: + DIGICERT_KEYLOCKER_CERTIFICATE: ${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE }} + DIGICERT_KEYLOCKER_PASSWORD: ${{ secrets.DIGICERT_KEYLOCKER_PASSWORD }} + DIGICERT_KEYLOCKER_HOST_URL: ${{ secrets.DIGICERT_KEYLOCKER_HOST_URL }} + DIGICERT_API_KEY: ${{ secrets.DIGICERT_API_KEY }} + DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT: ${{ secrets.DIGICERT_KEYLOCKER_CERTIFICATE_FINGERPRINT }} diff --git a/orbit/changes/17187-sign-windows b/orbit/changes/17187-sign-windows new file mode 100644 index 0000000000..b074822855 --- /dev/null +++ b/orbit/changes/17187-sign-windows @@ -0,0 +1 @@ +Windows orbit.exe and fleet-desktop.exe are now signed. From 3e5f7d5cf535242954ffc0d2a747b464c293db7f Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Fri, 26 Apr 2024 15:05:34 -0300 Subject: [PATCH 48/83] Fix users query for linux devices (#18454) #18424 I believe the hosts users for linux broke when we added Chrome support (https://github.com/fleetdm/fleet/pull/10281/files). - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - ~[ ] 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.~ - [x] Added/updated tests - ~[ ] 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.~ - ~[ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.~ - ~[ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).~ - [X] Manual QA for all new/changed functionality - ~For Orbit and Fleet Desktop changes:~ - ~[ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux.~ - ~[ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ --- changes/18424-fix-users-query-for-linux | 1 + server/service/osquery_test.go | 64 ++++++++++++++++++++----- server/service/osquery_utils/queries.go | 2 +- 3 files changed, 53 insertions(+), 14 deletions(-) create mode 100644 changes/18424-fix-users-query-for-linux diff --git a/changes/18424-fix-users-query-for-linux b/changes/18424-fix-users-query-for-linux new file mode 100644 index 0000000000..cf4c0affc2 --- /dev/null +++ b/changes/18424-fix-users-query-for-linux @@ -0,0 +1 @@ +* Fixed a bug with users not gathered on Linux devices. diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index d6e34624f6..10387de164 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -259,7 +259,10 @@ var allDetailQueries = osquery_utils.GetDetailQueries( context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, - &fleet.Features{EnableHostUsers: true}, + &fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }, ) func expectedDetailQueriesForPlatform(platform string) map[string]osquery_utils.DetailQuery { @@ -1027,7 +1030,11 @@ func TestHostDetailQueries(t *testing.T) { ds := new(mock.Store) additional := json.RawMessage(`{"foobar": "select foo", "bim": "bam"}`) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{AdditionalQueries: &additional, EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + AdditionalQueries: &additional, + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } mockClock := clock.NewMockClock() @@ -1311,7 +1318,10 @@ func TestLabelQueries(t *testing.T) { return nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } ds.PolicyQueriesForHostFunc = func(ctx context.Context, host *fleet.Host) (map[string]string, error) { return map[string]string{}, nil @@ -1468,7 +1478,10 @@ func TestDetailQueriesWithEmptyStrings(t *testing.T) { ctx = hostctx.NewContext(ctx, host) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } ds.LabelQueriesForHostFunc = func(context.Context, *fleet.Host) (map[string]string, error) { return map[string]string{}, nil @@ -1658,7 +1671,10 @@ func TestDetailQueries(t *testing.T) { lq.On("QueriesForHost", host.ID).Return(map[string]string{}, nil) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true, EnableSoftwareInventory: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } ds.LabelQueriesForHostFunc = func(context.Context, *fleet.Host) (map[string]string, error) { return map[string]string{}, nil @@ -1707,9 +1723,8 @@ func TestDetailQueries(t *testing.T) { // queries) queries, discovery, acc, err := svc.GetDistributedQueries(ctx) require.NoError(t, err) - // +2 for software inventory (+1 for the main software query +1 software_vscode_extensions) // +1 for fleet_no_policies_wildcard - if expected := expectedDetailQueriesForPlatform(host.Platform); !assert.Equal(t, len(expected)+2+1, len(queries)) { + if expected := expectedDetailQueriesForPlatform(host.Platform); !assert.Equal(t, len(expected)+1, len(queries)) { // this is just to print the diff between the expected and actual query // keys when the count assertion fails, to help debugging - they are not // expected to match. @@ -1975,9 +1990,8 @@ func TestDetailQueries(t *testing.T) { queries, discovery, acc, err = svc.GetDistributedQueries(ctx) require.NoError(t, err) - // +2 software inventory (+1 main software query and +1 software extra query ) // +1 fleet_no_policies_wildcard query - require.Equal(t, len(expectedDetailQueriesForPlatform(host.Platform))+2+1, len(queries), distQueriesMapKeys(queries)) + require.Equal(t, len(expectedDetailQueriesForPlatform(host.Platform))+1, len(queries), distQueriesMapKeys(queries)) verifyDiscovery(t, queries, discovery) assert.Zero(t, acc) } @@ -2156,7 +2170,10 @@ func TestDistributedQueryResults(t *testing.T) { return nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } hostCtx := hostctx.NewContext(ctx, host) @@ -3012,7 +3029,10 @@ func TestPolicyQueries(t *testing.T) { return nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } lq.On("QueriesForHost", uint(0)).Return(map[string]string{}, nil) @@ -3209,7 +3229,8 @@ func TestPolicyWebhooks(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ Features: fleet.Features{ - EnableHostUsers: true, + EnableHostUsers: true, + EnableSoftwareInventory: true, }, WebhookSettings: fleet.WebhookSettings{ FailingPoliciesWebhook: fleet.FailingPoliciesWebhookSettings{ @@ -3477,7 +3498,10 @@ func TestLiveQueriesFailing(t *testing.T) { return host, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{Features: fleet.Features{EnableHostUsers: true}}, nil + return &fleet.AppConfig{Features: fleet.Features{ + EnableHostUsers: true, + EnableSoftwareInventory: true, + }}, nil } ds.PolicyQueriesForHostFunc = func(ctx context.Context, host *fleet.Host) (map[string]string, error) { return map[string]string{}, nil @@ -3765,3 +3789,17 @@ func TestPreProcessSoftwareResults(t *testing.T) { }) } } + +func TestDetailQueriesLinuxDistros(t *testing.T) { + for _, linuxPlatform := range fleet.HostLinuxOSs { + m := expectedDetailQueriesForPlatform(linuxPlatform) + require.Contains(t, m, "users") + require.Contains(t, m, "network_interface_unix") + require.Contains(t, m, "disk_space_unix") + require.Contains(t, m, "os_unix_like") + require.Contains(t, m, "orbit_info") + require.Contains(t, m, "disk_encryption_linux") + require.Contains(t, m, "software_vscode_extensions") + require.Contains(t, m, "software_linux") + } +} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index f9a6af2bcd..ecbbf264ca 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -1055,7 +1055,7 @@ var usersQuery = DetailQuery{ // with many user accounts and groups, this query could be very expensive as the `groups` table // was generated once for each user. Query: usersQueryStr, - Platforms: []string{"linux", "darwin", "windows"}, + Platforms: append(fleet.HostLinuxOSs, "darwin", "windows"), DirectIngestFunc: directIngestUsers, } From ee6c59d01f9f309017af0559682f434bf1694910 Mon Sep 17 00:00:00 2001 From: Isabell Reedy <113355639+ireedy@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:02:41 -0400 Subject: [PATCH 49/83] Add Gabe Lopez to engineering page and MDM product group (#18523) --- handbook/company/product-groups.md | 2 +- handbook/engineering/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index d85d80eeb7..cf1fa78169 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -52,7 +52,7 @@ The goal of the MDM group is to increase and exceed [Fleet's product maturity go | Product Designer | [Marko Lisica](https://www.linkedin.com/in/markolisica/) _([@marko-lisica](https://github.com/marko-lisica))_ | Engineering Manager | [George Karr](https://www.linkedin.com/in/george-karr-4977b441/) _([@georgekarrv](https://github.com/georgekarrv))_ | Product Manager | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/@noahtalerman))_ -| Quality Assurance | [Position open](https://www.fleetdm.com/jobs/) +| Quality Assurance | [Gabe Lopez](https://www.linkedin.com/in/gabelopez/) _([@PezHub](https://github.com/PezHub))_ | Developer | [Gabe Hernandez](https://www.linkedin.com/in/gabriel-hernandez-gh) _([@ghernandez345](https://github.com/ghernandez345))_, [Roberto Dip](https://www.linkedin.com/in/roperzh) _([@roperzh](https://github.com/roperzh))_, Sarah Gillespie _([@gillespi314](https://github.com/gillespi314))_, [Martin Angers](https://www.linkedin.com/in/martin-angers-3210305/) _([@mna](https://github.com/mna))_, [Jahziel Villasana-Espinoza](https://www.linkedin.com/in/jahziel-v/) _([@jahzielv](https://github.com/jahzielv))_, [Dante Catalfamo](https://www.linkedin.com/in/dante-catalfamo-a6330412b/) _([@dantecatalfamo](https://github.com/dantecatalfamo))_ > 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`. diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 63b7ce87e6..c12efec050 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -6,7 +6,7 @@ This handbook page details processes specific to working [with](#team) and [with |:--------------------------------|:-----------------------------------------------------------------------------------------------------------| | CTO | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ | Engineering Manager | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ -| Quality Assurance | [Reed Haynes](https://www.linkedin.com/in/reed-haynes-633a69a3/) _([@xpkoala](https://github.com/xpkoala))_ +| Quality Assurance | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ | Developer | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ ## Contact us From 13f4703f0190c93fb7c88ddf724732bb99c3c101 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:14:49 -0400 Subject: [PATCH 50/83] Fleet UI: Activity log expiry settings (#18243) --- changes/16989-ui-to-delete-old-activities | 1 + frontend/__mocks__/configMock.ts | 4 + frontend/interfaces/config.ts | 45 ++----- .../cards/Advanced/Advanced.tsx | 112 ++++++++++++++---- .../OrgSettingsPage/cards/Agents/Agents.tsx | 4 +- .../cards/FleetDesktop/FleetDesktop.tsx | 21 ++-- .../GlobalHostStatusWebhook.tsx | 41 ++++--- .../admin/OrgSettingsPage/cards/Info/Info.tsx | 29 +++-- .../admin/OrgSettingsPage/cards/Smtp/Smtp.tsx | 47 +++++--- .../admin/OrgSettingsPage/cards/Sso/Sso.tsx | 54 +++++---- .../cards/Statistics/Statistics.tsx | 14 ++- .../cards/WebAddress/WebAddress.tsx | 24 ++-- .../admin/OrgSettingsPage/cards/constants.ts | 24 ---- frontend/utilities/constants.tsx | 6 + 14 files changed, 252 insertions(+), 174 deletions(-) create mode 100644 changes/16989-ui-to-delete-old-activities diff --git a/changes/16989-ui-to-delete-old-activities b/changes/16989-ui-to-delete-old-activities new file mode 100644 index 0000000000..6897b0212a --- /dev/null +++ b/changes/16989-ui-to-delete-old-activities @@ -0,0 +1 @@ +- Add advanced setting to set expiry window for activity log diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 5ede26b805..af7bd63524 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -46,6 +46,10 @@ const DEFAULT_CONFIG_MOCK: IConfig = { host_expiry_enabled: false, host_expiry_window: 0, }, + activity_expiry_settings: { + activity_expiry_enabled: true, + activity_expiry_window: 90, + }, agent_options: "", license: { tier: "free", diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index f5f52a544d..bc8f0cb759 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -65,41 +65,6 @@ export interface IFleetDesktopSettings { transparency_url: string; } -export interface IConfigFormData { - smtpAuthenticationMethod: string; - smtpAuthenticationType: string; - domain: string; - smtpEnableSslTls: boolean; - enableStartTls: boolean; - serverUrl: string; - orgLogoUrl: string; - orgName: string; - smtpPassword: string; - smtpPort?: number; - smtpSenderAddress: string; - smtpServer: string; - smtpUsername: string; - verifySslCerts: boolean; - entityId: string; - idpImageUrl: string; - metadata: string; - metadataUrl: string; - idpName: string; - enableSso: boolean; - enableSsoIdpLogin: boolean; - enableSmtp: boolean; - enableHostExpiry: boolean; - hostExpiryWindow: number; - disableLiveQuery: boolean; - agentOptions: any; - enableHostStatusWebhook: boolean; - hostStatusWebhookDestinationUrl?: string; - hostStatusWebhookHostPercentage?: number; - hostStatusWebhookDaysCount?: number; - enableUsageStatistics: boolean; - transparencyUrl: string; -} - export interface IConfigFeatures { enable_host_users: boolean; enable_software_inventory: boolean; @@ -125,7 +90,7 @@ export interface IConfig { server_settings: IConfigServerSettings; smtp_settings?: { enable_smtp: boolean; - configured: boolean; + configured?: boolean; sender_address: string; server: string; port?: number; @@ -152,10 +117,14 @@ export interface IConfig { }; host_expiry_settings: { host_expiry_enabled: boolean; - host_expiry_window: number; + host_expiry_window?: number; + }; + activity_expiry_settings: { + activity_expiry_enabled: boolean; + activity_expiry_window?: number; }; features: IConfigFeatures; - agent_options: string; + agent_options: unknown; // Can pass empty object update_interval: { osquery_detail: number; osquery_policy: number; diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx index dc882ba0be..b01f9c079a 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Advanced/Advanced.tsx @@ -1,31 +1,53 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import SectionHeader from "components/SectionHeader"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; -import { - IAppConfigFormProps, - IFormField, - IAppConfigFormErrors, -} from "../constants"; +import { ACTIVITY_EXPIRY_WINDOW_DROPDOWN_OPTIONS } from "utilities/constants"; +import { getCustomDropdownOptions } from "utilities/helpers"; + +import { IAppConfigFormProps, IFormField } from "../constants"; const baseClass = "app-config-form"; +interface IAdvancedConfigFormData { + domain: string; + verifySSLCerts: boolean; + enableStartTLS?: boolean; + enableHostExpiry: boolean; + hostExpiryWindow: number; + deleteActivities: boolean; + activityExpiryWindow: number; + disableLiveQuery: boolean; + disableScripts: boolean; + disableQueryReports: boolean; +} + +interface IAdvancedConfigFormErrors { + host_expiry_window?: string | null; +} + const Advanced = ({ appConfig, handleSubmit, isUpdatingSettings, }: IAppConfigFormProps): JSX.Element => { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ domain: appConfig.smtp_settings?.domain || "", verifySSLCerts: appConfig.smtp_settings?.verify_ssl_certs || false, enableStartTLS: appConfig.smtp_settings?.enable_start_tls, enableHostExpiry: appConfig.host_expiry_settings.host_expiry_enabled || false, hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0, + deleteActivities: + appConfig.activity_expiry_settings?.activity_expiry_enabled || false, + activityExpiryWindow: + appConfig.activity_expiry_settings?.activity_expiry_window || 30, disableLiveQuery: appConfig.server_settings.live_query_disabled || false, disableQueryReports: appConfig.server_settings.query_reports_disabled || false, @@ -38,20 +60,35 @@ const Advanced = ({ enableStartTLS, enableHostExpiry, hostExpiryWindow, + deleteActivities, + activityExpiryWindow, disableLiveQuery, disableScripts, disableQueryReports, } = formData; - const [formErrors, setFormErrors] = useState({}); + const [formErrors, setFormErrors] = useState({}); - const handleInputChange = ({ name, value }: IFormField) => { + const activityExpiryWindowOptions = useMemo( + () => + getCustomDropdownOptions( + ACTIVITY_EXPIRY_WINDOW_DROPDOWN_OPTIONS, + activityExpiryWindow, + // it's safe to assume that frequency is a number + (frequency: number | string) => `${frequency as number} days` + ), + // intentionally leave activityExpiryWindow out of the dependencies, so that the custom + // options are maintained even if the user changes the frequency in the UI + [deleteActivities] + ); + + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); }; useEffect(() => { // validate desired form fields - const errors: IAppConfigFormErrors = {}; + const errors: IAdvancedConfigFormErrors = {}; if (enableHostExpiry && (!hostExpiryWindow || hostExpiryWindow <= 0)) { errors.host_expiry_window = @@ -70,15 +107,20 @@ const Advanced = ({ live_query_disabled: disableLiveQuery, query_reports_disabled: disableQueryReports, scripts_disabled: disableScripts, + deferred_save_host: appConfig.server_settings.deferred_save_host, }, smtp_settings: { domain, verify_ssl_certs: verifySSLCerts, - enable_start_tls: enableStartTLS, + enable_start_tls: enableStartTLS || false, }, host_expiry_settings: { host_expiry_enabled: enableHostExpiry, - host_expiry_window: Number(hostExpiryWindow), + host_expiry_window: hostExpiryWindow || undefined, + }, + activity_expiry_settings: { + activity_expiry_enabled: deleteActivities, + activity_expiry_window: activityExpiryWindow || undefined, }, }; @@ -95,7 +137,7 @@ const Advanced = ({

)} + When enabled, allows automatic cleanup of audit logs older than + the number of days specified in the{" "} + Audit log retention window setting. + + (Default: Off) + + + } + > + Delete activities + + {deleteActivities && ( + + )} + { - const [formData, setFormData] = useState< - Pick - >({ + const [formData, setFormData] = useState({ transparencyUrl: appConfig.fleet_desktop?.transparency_url || DEFAULT_TRANSPARENCY_URL, }); - const [formErrors, setFormErrors] = useState({}); + const [formErrors, setFormErrors] = useState({}); - const handleInputChange = ({ value }: IFormField) => { + const onInputChange = ({ value }: IFormField) => { setFormData({ transparencyUrl: value.toString() }); setFormErrors({}); }; @@ -41,7 +44,7 @@ const FleetDesktop = ({ const validateForm = () => { const { transparencyUrl } = formData; - const errors: IAppConfigFormErrors = {}; + const errors: IFleetDesktopFormErrors = {}; if (transparencyUrl && !validUrl({ url: transparencyUrl })) { errors.transparency_url = `${transparencyUrl} is not a valid URL`; } @@ -72,7 +75,7 @@ const FleetDesktop = ({ ({}); + const [ + formErrors, + setFormErrors, + ] = useState({}); - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); setFormErrors({}); }; const validateForm = () => { - const errors: IAppConfigFormErrors = {}; + const errors: IGlobalHostStatusWebhookFormErrors = {}; if (enableHostStatusWebhook) { if (!destination_url) { @@ -103,6 +106,10 @@ const GlobalHostStatusWebhook = ({ host_percentage: hostStatusWebhookHostPercentage, days_count: hostStatusWebhookWindow, }, + failing_policies_webhook: + appConfig.webhook_settings.failing_policies_webhook, + vulnerabilities_webhook: + appConfig.webhook_settings.vulnerabilities_webhook, }, }; @@ -139,7 +146,7 @@ const GlobalHostStatusWebhook = ({ Send an alert if a portion of your hosts go offline.

({}); + const [formErrors, setFormErrors] = useState({}); - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); setFormErrors({}); }; const validateForm = () => { - const errors: IAppConfigFormErrors = {}; + const errors: IOrgInfoFormErrors = {}; if (!orgName) { errors.org_name = "Organization name must be present"; @@ -101,7 +104,7 @@ const Info = ({ { const { isPremiumTier } = useContext(AppContext); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ enableSMTP: appConfig.smtp_settings?.enable_smtp || false, smtpSenderAddress: appConfig.smtp_settings?.sender_address || "", smtpServer: appConfig.smtp_settings?.server || "", @@ -55,16 +74,16 @@ const Smtp = ({ smtpAuthenticationMethod, } = formData; - const [formErrors, setFormErrors] = useState({}); + const [formErrors, setFormErrors] = useState({}); const sesConfigured = appConfig.email?.backend === "ses" || false; - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); }; const validateForm = () => { - const errors: IAppConfigFormErrors = {}; + const errors: ISmtpConfigFormErrors = {}; if (enableSMTP) { if (!smtpSenderAddress) { @@ -131,7 +150,7 @@ const Smtp = ({ <>
({}); + const [formErrors, setFormErrors] = useState({}); - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); }; const validateForm = () => { - const errors: IAppConfigFormErrors = {}; + const errors: ISsoFormErrors = {}; if (enableSso) { if (idpImageUrl && !validUrl({ url: idpImageUrl })) { @@ -115,6 +119,8 @@ const Sso = ({ enable_sso: enableSso, enable_sso_idp_login: enableSsoIdpLogin, enable_jit_provisioning: enableJitProvisioning, + issuer_uri: appConfig.sso_settings.issuer_uri, + enable_jit_role_sync: appConfig.sso_settings.enable_jit_role_sync, }, }; @@ -127,7 +133,7 @@ const Sso = ({ {isPremiumTier && ( { - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ enableUsageStatistics: appConfig.server_settings.enable_analytics, }); const { enableUsageStatistics } = formData; - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); }; @@ -32,6 +36,10 @@ const Statistics = ({ const formDataToSubmit = { server_settings: { enable_analytics: enableUsageStatistics, + deferred_save_host: appConfig.server_settings.deferred_save_host, + query_reports_disabled: + appConfig.server_settings.query_reports_disabled, + scripts_disabled: appConfig.server_settings.scripts_disabled, }, }; @@ -60,7 +68,7 @@ const Statistics = ({ />

{ - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ serverURL: appConfig.server_settings.server_url || "", }); const { serverURL } = formData; - const [formErrors, setFormErrors] = useState({}); + const [formErrors, setFormErrors] = useState({}); - const handleInputChange = ({ name, value }: IFormField) => { + const onInputChange = ({ name, value }: IFormField) => { setFormData({ ...formData, [name]: value }); setFormErrors({}); }; const validateForm = () => { - const errors: IAppConfigFormErrors = {}; + const errors: IWebAddressFormErrors = {}; if (!serverURL) { errors.server_url = "Fleet server URL must be present"; } else if (!validUrl({ url: serverURL, protocols: ["http", "https"] })) { @@ -68,7 +72,7 @@ const WebAddress = ({ Include base path only (eg. no /latest) } - onChange={handleInputChange} + onChange={onInputChange} name="serverURL" value={serverURL} parseTarget diff --git a/frontend/pages/admin/OrgSettingsPage/cards/constants.ts b/frontend/pages/admin/OrgSettingsPage/cards/constants.ts index 3e314baa83..ccf2aab3d4 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/constants.ts +++ b/frontend/pages/admin/OrgSettingsPage/cards/constants.ts @@ -20,30 +20,6 @@ export interface IFormField { value: string | boolean | number; } -export interface IAppConfigFormErrors { - metadata?: string | null; - metadata_url?: string | null; - entity_id?: string | null; - idp_name?: string | null; - server_url?: string | null; - org_name?: string | null; - org_logo_url?: string | null; - org_logo_url_light_background?: string | null; - org_support_url?: string | null; - idp_image_url?: string | null; - sender_address?: string | null; - server?: string | null; - server_port?: string | null; - user_name?: string | null; - password?: string | null; - destination_url?: string | null; - days_count?: string | null; - host_percentage?: string | null; - host_expiry_window?: string | null; - agent_options?: string | null; - transparency_url?: string | null; -} - export const authMethodOptions = [ { label: "Plain", value: "authmethod_plain" }, { label: "Cram MD5", value: "authmethod_cram_md5" }, diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index a3e6490daa..b3aaca9503 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -26,6 +26,12 @@ export const DEFAULT_GRAVATAR_LINK_FALLBACK = export const DEFAULT_GRAVATAR_LINK_DARK_FALLBACK = "/assets/images/icon-avatar-default-dark-24x24%402x.png"; +export const ACTIVITY_EXPIRY_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [ + { value: 30, label: "30 days" }, + { value: 60, label: "60 days" }, + { value: 90, label: "90 days" }, +]; + export const FREQUENCY_DROPDOWN_OPTIONS: IDropdownOption[] = [ { value: 0, label: "Never" }, { value: 300, label: "Every 5 minutes" }, From 1cb670a3c18420d1c34cd3da64a7bc94c3108d74 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Fri, 26 Apr 2024 15:30:58 -0400 Subject: [PATCH 51/83] Tools: Choose Enrollment VM base image using environment variable (#18070) Good for running out older versions of macOS during an semi-automated test --- tools/mdm/apple/macos-vm-auto-enroll/README.md | 1 + .../mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/mdm/apple/macos-vm-auto-enroll/README.md b/tools/mdm/apple/macos-vm-auto-enroll/README.md index 7f50931750..3045544f71 100644 --- a/tools/mdm/apple/macos-vm-auto-enroll/README.md +++ b/tools/mdm/apple/macos-vm-auto-enroll/README.md @@ -9,6 +9,7 @@ The script takes no arguments, but can be configured through three environment v - `FLEET_ENROLL_SECRET` (required) The fleet enrollment secret - `FLEET_URL` (required) The fleet base url - `MACOS_ENROLLMENT_VM_NAME` (optional) The name of the VM. If nothing is specified, the default name is `enrollment-test`. +- `MACOS_ENROLLMENT_VM_IMAGE` (optional) The image to use for the VM. If nothing is specified, the default image is `ghcr.io/cirruslabs/macos-sonoma-base:latest` The entire process from the generation of the `pkg` file to the installation is automated. The only part that requires user intervention is installing the MDM profile. diff --git a/tools/mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh b/tools/mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh index 32b469dd04..2d15275e75 100755 --- a/tools/mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh +++ b/tools/mdm/apple/macos-vm-auto-enroll/macos-vm-auto-enroll.sh @@ -6,9 +6,13 @@ set -m # Fleet enroll secret placed in $FLEET_ENROLL_SECRET # Fleet URL placed in $FLEET_URL # Optional VM name in $MACOS_ENROLLMENT_VM_NAME +# Optional VM image in $MACOS_ENROLLMENT_VM_IMAGE +# For others see https://tart.run/quick-start/ +# - ghcr.io/cirruslabs/macos-ventura-base:latest +# - ghcr.io/cirruslabs/macos-monterey-base:latest vm_name="${MACOS_ENROLLMENT_VM_NAME:-enrollment-test}" -image_name="ghcr.io/cirruslabs/macos-sonoma-base:latest" +image_name="${MACOS_ENROLLMENT_VM_IMAGE:-ghcr.io/cirruslabs/macos-sonoma-base:latest}" alias ssh_cmd="sshpass -p admin ssh -o \"StrictHostKeyChecking no\" admin@\$(tart ip $vm_name)" alias ssh_interactive_cmd="sshpass -p admin ssh -o \"StrictHostKeyChecking no\" -t admin@\$(tart ip $vm_name)" From 8bcdd93f2cc79aa7668aaa7b3c145e3ebb22880d Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Fri, 26 Apr 2024 15:10:28 -0500 Subject: [PATCH 52/83] Update `fleetctl package` usage description (#18545) Slight verbiage change requested by @noahtalerman re: #16512 --- cmd/fleetctl/package.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/fleetctl/package.go b/cmd/fleetctl/package.go index 531c734507..ac64ab9035 100644 --- a/cmd/fleetctl/package.go +++ b/cmd/fleetctl/package.go @@ -30,7 +30,7 @@ func packageCommand() *cli.Command { return &cli.Command{ Name: "package", Aliases: nil, - Usage: "Create a fleetd package", + Usage: "Create a fleetd agent", Description: "An easy way to create fully boot-strapped installer packages for Windows, macOS, or Linux", Flags: []cli.Flag{ &cli.StringFlag{ From 4bac6ca321e568b055962cebfc87b4369dbdcc99 Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 26 Apr 2024 15:37:47 -0500 Subject: [PATCH 53/83] Website: update SOC 2 link in footer (#18577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: https://github.com/fleetdm/confidential/issues/6319 Changes: - Updated the website footer ("SOC2 Type 2 certified" » "SOC2 Type 2") --- website/views/layouts/layout.ejs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 66e8709841..def70805fb 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -337,7 +337,7 @@
© <%= (new Date()).getFullYear() %> <%= corporationDisplayName %> @@ -378,7 +378,7 @@
© <%= (new Date()).getFullYear() %> <%= corporationDisplayName %> From 8eec8e5371b7edd6739ccec8e1c8eb2a6c49fc34 Mon Sep 17 00:00:00 2001 From: JD Date: Fri, 26 Apr 2024 16:02:16 -0700 Subject: [PATCH 54/83] Update spelling & grammar in pwd_policy.yml (#18547) Markdown updates to spelling and grammar. --- schema/tables/pwd_policy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/tables/pwd_policy.yml b/schema/tables/pwd_policy.yml index f6c0fd920b..6f0bf193ca 100644 --- a/schema/tables/pwd_policy.yml +++ b/schema/tables/pwd_policy.yml @@ -1,7 +1,7 @@ name: pwd_policy platforms: - darwin -description: Password Policiy (e.g max failed password attempts). +description: Password Policy (e.g., max failed password attempts). columns: - name: max_failed_attempts type: integer From 26b83de92582442fcb747248e60aeb0d4fd665d0 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 29 Apr 2024 08:48:54 -0400 Subject: [PATCH 55/83] Fix sending of "install Fleetd" commands repeatedly on Azure-enrolled Windows devices (#18453) --- ...-install-commands-of-fleetd-on-windows-mdm | 1 + server/datastore/mysql/hosts.go | 18 ++-- server/fleet/hosts.go | 4 +- server/service/integration_mdm_test.go | 85 ++++++++++++++++--- server/service/microsoft_mdm.go | 18 +++- 5 files changed, 102 insertions(+), 24 deletions(-) create mode 100644 changes/18085-fix-repeated-install-commands-of-fleetd-on-windows-mdm diff --git a/changes/18085-fix-repeated-install-commands-of-fleetd-on-windows-mdm b/changes/18085-fix-repeated-install-commands-of-fleetd-on-windows-mdm new file mode 100644 index 0000000000..ee04c643b4 --- /dev/null +++ b/changes/18085-fix-repeated-install-commands-of-fleetd-on-windows-mdm @@ -0,0 +1 @@ +* Fixed an issue on Windows hosts enrolled in MDM via Azure AD where the command to install Fleetd on the device was sent repeatedly, even though `fleetd` had been properly installed. diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 9c799edf59..ce86ef3f4e 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -2680,13 +2680,13 @@ func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (* COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available, COALESCE(hd.gigs_total_disk_space, 0) as gigs_total_disk_space, COALESCE(hst.seen_time, h.created_at) AS seen_time, - COALESCE(hu.software_updated_at, h.created_at) AS software_updated_at - ` + hostMDMSelect + ` + COALESCE(hu.software_updated_at, h.created_at) AS software_updated_at + ` + hostMDMSelect + ` FROM hosts h LEFT JOIN host_seen_times hst ON (h.id = hst.host_id) - LEFT JOIN host_updates hu ON (h.id = hu.host_id) + LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN host_disks hd ON hd.host_id = h.id - ` + hostMDMJoin + ` + ` + hostMDMJoin + ` WHERE ? IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial) LIMIT 1 ` @@ -3715,9 +3715,11 @@ func (ds *Datastore) GetHostOrbitInfo(ctx context.Context, hostID uint) (*fleet. err := sqlx.GetContext( ctx, ds.reader(ctx), &orbit, ` SELECT - scripts_enabled + version, + desktop_version, + scripts_enabled FROM - host_orbit_info + host_orbit_info WHERE host_id = ?`, hostID, ) if err != nil { @@ -5037,7 +5039,7 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str stmt := ` SELECT h.id, - h.team_id, + h.team_id, h.osquery_host_id, h.node_key, h.hostname, @@ -5048,7 +5050,7 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str COALESCE(hst.seen_time, h.created_at) AS seen_time FROM hosts h LEFT JOIN host_seen_times hst ON (h.id = hst.host_id) - %s + %s LIMIT 1 ` var ( diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index ae0124c0d8..0ed16f60c3 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -361,7 +361,9 @@ type Host struct { // HostOrbitInfo maps to the host_orbit_info table in the database, which maps to the orbit_info agent table. type HostOrbitInfo struct { - ScriptsEnabled *bool `json:"scripts_enabled" db:"scripts_enabled"` + Version string `json:"version" db:"version"` + DesktopVersion *string `json:"desktop_version" db:"desktop_version"` + ScriptsEnabled *bool `json:"scripts_enabled" db:"scripts_enabled"` } // HostHealth contains a subset of Host data that indicates how healthy a Host is. For fields with diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 4510cfc2e6..dedd92f3e0 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -6069,22 +6069,79 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() { d := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail) require.NoError(t, d.Enroll()) - cmds, err := d.StartManagementSession() + checkinAndAck := func(expectFleetdCmds bool) { + cmds, err := d.StartManagementSession() + require.NoError(t, err) + + if !expectFleetdCmds { + // receives only the 2 status commands + require.Len(t, cmds, 2) + for _, c := range cmds { + require.Equal(t, "Status", c.Verb, c) + } + return + } + + // 2 status + 2 commands to install fleetd + require.Len(t, cmds, 4) + var fleetdAddCmd, fleetdExecCmd fleet.ProtoCmdOperation + for _, c := range cmds { + switch c.Verb { + case "Add": + fleetdAddCmd = c + case "Exec": + fleetdExecCmd = c + } + } + require.Equal(t, syncml.FleetdWindowsInstallerGUID, fleetdAddCmd.Cmd.GetTargetURI()) + require.Equal(t, syncml.FleetdWindowsInstallerGUID, fleetdExecCmd.Cmd.GetTargetURI()) + + // reply with success for both commands + msgID, err := d.GetCurrentMsgID() + require.NoError(t, err) + + d.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: fleet.CmdStatus}, + MsgRef: &msgID, + CmdRef: &fleetdAddCmd.Cmd.CmdID.Value, + Cmd: &fleetdAddCmd.Verb, + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + d.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: fleet.CmdStatus}, + MsgRef: &msgID, + CmdRef: &fleetdExecCmd.Cmd.CmdID.Value, + Cmd: &fleetdExecCmd.Verb, + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + cmds, err = d.SendResponse() + require.NoError(t, err) + + // the ack of the message should be the only returned command + require.Len(t, cmds, 1) + } + + // start a management session, will receive the install fleetd commands + checkinAndAck(true) + + // start a new management session again, Fleetd is not reported as installed + // so it receives the commands again + checkinAndAck(true) + + // simulate fleetd installed and enrolled + host := createOrbitEnrolledHost(t, "windows", "h1", s.ds) + err = s.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, d.DeviceID) + require.NoError(t, err) + err = s.ds.SetOrUpdateHostOrbitInfo(ctx, host.ID, "1.23", sql.NullString{}, sql.NullBool{}) require.NoError(t, err) - // 2 status + 2 commands to install fleetd - require.Len(t, cmds, 4) - var fleetdAddCmd, fleetdExecCmd fleet.ProtoCmdOperation - for _, c := range cmds { - switch c.Verb { - case "Add": - fleetdAddCmd = c - case "Exec": - fleetdExecCmd = c - } - } - require.Equal(t, syncml.FleetdWindowsInstallerGUID, fleetdAddCmd.Cmd.GetTargetURI()) - require.Equal(t, syncml.FleetdWindowsInstallerGUID, fleetdExecCmd.Cmd.GetTargetURI()) + // start a new management session again, Fleetd is reported as installed so + // it does not receive the commands + checkinAndAck(false) } func (s *integrationMDMTestSuite) TestValidManagementUnenrollRequest() { diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 1a6e113f50..4ceab6d20b 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -1274,7 +1274,23 @@ func (svc *Service) isFleetdPresentOnDevice(ctx context.Context, deviceID string // If user identity is a MS-MDM UPN it means that the device was enrolled through user-driven flow // This means that fleetd might not be installed if isValidUPN(enrolledDevice.MDMEnrollUserID) { - return false, nil + var isPresent bool + if enrolledDevice.HostUUID != "" { + host, err := svc.ds.HostLiteByIdentifier(ctx, enrolledDevice.HostUUID) + if err != nil && !fleet.IsNotFound(err) { + return false, ctxerr.Wrap(ctx, err, "get host lite by identifier") + } + if host != nil { + orbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) + if err != nil && !fleet.IsNotFound(err) { + return false, ctxerr.Wrap(ctx, err, "get host orbit info") + } + if orbitInfo != nil { + isPresent = orbitInfo.Version != "" + } + } + } + return isPresent, nil } // TODO: Add check here to determine if MDM DeviceID is connected with Smbios UUID present on From 9b3d6e590d853364b7b0a38695e663b2742b57e9 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Mon, 29 Apr 2024 09:55:05 -0400 Subject: [PATCH 56/83] Linux Wipe Async (#18372) #18173 Creates a detached child shell process to wipe the host. This lets the main shell script return and tells the server the command has completed, clearing it from the run queue. Now shuts the host down after being wiped instead of leaving it running without files like a zombie --- changes/18173-linux-async-wipe | 1 + .../service/embedded_scripts/linux_wipe.sh | 35 ++++++++++++++++--- scripts/mdm/linux/linux-wipe.sh | 20 ++++++++--- 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 changes/18173-linux-async-wipe diff --git a/changes/18173-linux-async-wipe b/changes/18173-linux-async-wipe new file mode 100644 index 0000000000..c127c851d2 --- /dev/null +++ b/changes/18173-linux-async-wipe @@ -0,0 +1 @@ +* Fixed bug where Linux host wipe would repeat if the host got re-enrolled diff --git a/ee/server/service/embedded_scripts/linux_wipe.sh b/ee/server/service/embedded_scripts/linux_wipe.sh index 69a78b1235..859c285ea5 100644 --- a/ee/server/service/embedded_scripts/linux_wipe.sh +++ b/ee/server/service/embedded_scripts/linux_wipe.sh @@ -38,9 +38,34 @@ wipe_system_files() { done } -# Start the wiping process -logout_users -wipe_non_essential_data -wipe_system_files +prepare_system_reset() { + cp /usr/bin/sync /sync_bin + # https://docs.kernel.org/admin-guide/sysrq.html + echo "1" > /proc/sys/kernel/sysrq +} -echo "Wiping process completed." +system_reset() { + # Give the system time to sync + /sync_bin + # Halt the system immediately + echo "o" > /proc/sysrq-trigger +} + +wipe_all_files() { + sleep 10 # Give fleetd enough time to register the script as completed + prepare_system_reset + wipe_non_essential_data + wipe_system_files + system_reset +} + +if [ "$1" = "wipe" ]; then + # We are in the detatched child process + wipe_all_files +else + # We are in the parent shell, logout users and begin the detached + # wipe child process + logout_users + echo "Wiping, system will be unreachable" + (/usr/bin/nohup sh $0 wipe >/dev/null 2>/dev/null /dev/null 2>/dev/null Date: Mon, 29 Apr 2024 09:56:04 -0400 Subject: [PATCH 57/83] `fleetctl get teams` prints team id (#18395) #18394 --- changes/18394-print-team-id | 1 + cmd/fleetctl/get.go | 8 +++++++- cmd/fleetctl/get_test.go | 14 +++++++------- cmd/fleetctl/testdata/expectedGetTeamsJson.json | 4 ++-- cmd/fleetctl/testdata/expectedGetTeamsText.txt | 14 +++++++------- 5 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 changes/18394-print-team-id diff --git a/changes/18394-print-team-id b/changes/18394-print-team-id new file mode 100644 index 0000000000..3dec347385 --- /dev/null +++ b/changes/18394-print-team-id @@ -0,0 +1 @@ +* fleetctl prints team id as part of the `fleetctl get teams` command diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go index 3fbc6274a3..c722755654 100644 --- a/cmd/fleetctl/get.go +++ b/cmd/fleetctl/get.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "sort" "strconv" "time" @@ -1194,14 +1195,19 @@ func getTeamsCommand() *cli.Command { // Default to printing as table data := [][]string{} + sort.Slice(teams, func(i, j int) bool { + return teams[i].Name < teams[j].Name + }) + for _, team := range teams { data = append(data, []string{ team.Name, + strconv.Itoa(int(team.ID)), fmt.Sprintf("%d", team.HostCount), fmt.Sprintf("%d", team.UserCount), }) } - columns := []string{"Team name", "Host count", "User count"} + columns := []string{"Team name", "Team ID", "Host count", "User count"} printTable(c, columns, data) return nil diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 8fe2d5103a..9c4781d301 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -132,7 +132,7 @@ func TestGetTeams(t *testing.T) { require.NoError(t, err) return []*fleet.Team{ { - ID: 42, + ID: 12, CreatedAt: created_at, Name: "team1", Description: "team1 description", @@ -146,7 +146,7 @@ func TestGetTeams(t *testing.T) { }, }, { - ID: 43, + ID: 32, CreatedAt: created_at, Name: "team2", Description: "team2 description", @@ -246,11 +246,11 @@ func TestGetTeamsByName(t *testing.T) { }, nil } - expectedText := `+-----------+------------+------------+ -| TEAM NAME | HOST COUNT | USER COUNT | -+-----------+------------+------------+ -| team1 | 43 | 99 | -+-----------+------------+------------+ + expectedText := `+-----------+---------+------------+------------+ +| TEAM NAME | TEAM ID | HOST COUNT | USER COUNT | ++-----------+---------+------------+------------+ +| team1 | 42 | 43 | 99 | ++-----------+---------+------------+------------+ ` assert.Equal(t, expectedText, runAppForTest(t, []string{"get", "teams", "--name", "test1"})) } diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 0a192e7343..ad84fe3d91 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -3,7 +3,7 @@ "apiVersion": "v1", "spec": { "team": { - "id": 42, + "id": 12, "created_at": "1999-03-10T02:45:06.371Z", "name": "team1", "description": "team1 description", @@ -63,7 +63,7 @@ "apiVersion": "v1", "spec": { "team": { - "id": 43, + "id": 32, "created_at": "1999-03-10T02:45:06.371Z", "name": "team2", "description": "team2 description", diff --git a/cmd/fleetctl/testdata/expectedGetTeamsText.txt b/cmd/fleetctl/testdata/expectedGetTeamsText.txt index e9bd4dd413..f47497947e 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsText.txt +++ b/cmd/fleetctl/testdata/expectedGetTeamsText.txt @@ -1,7 +1,7 @@ -+-----------+------------+------------+ -| TEAM NAME | HOST COUNT | USER COUNT | -+-----------+------------+------------+ -| team1 | 42 | 99 | -+-----------+------------+------------+ -| team2 | 43 | 87 | -+-----------+------------+------------+ ++-----------+---------+------------+------------+ +| TEAM NAME | TEAM ID | HOST COUNT | USER COUNT | ++-----------+---------+------------+------------+ +| team1 | 12 | 42 | 99 | ++-----------+---------+------------+------------+ +| team2 | 32 | 43 | 87 | ++-----------+---------+------------+------------+ From 61e3e0c8ab223e3bc2898c05f8b600759b165bd5 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Mon, 29 Apr 2024 09:34:47 -0500 Subject: [PATCH 58/83] Handbook: Update product-groups.md (#18590) (The numbers were confusing. It was like: "(1) P0, (2) P1, (3) P2") --- handbook/company/product-groups.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index cf1fa78169..fc66b775b3 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -431,20 +431,20 @@ Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" c ## High priority user stories and bugs All issues are treated as standard priority by default. Some issues are assigned a priority label to indicate urgency for the business. -1. Emergency: `P0` -- Examples: Customer outage, confirmed security vulnerability ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a new feature is needed to address an immediate business emergency. -- Response: Immediately stop other work to swarm the issue. Work 24/7 in shifts until resolved. -- Impact: Significant impact. May void current sprint. +- Emergency: `P0` + - Examples: Customer outage, confirmed security vulnerability ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a new feature is needed to address an immediate business emergency. + - Response: Immediately stop other work to swarm the issue. Work 24/7 in shifts until resolved. + - Impact: Significant impact. May void current sprint. -2. Critical: `P1` -- Examples: A supported workflow is broken ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a potential security vulnerability, a new feature is required to address an immediate critical business need. -- Response: Issue brought to next standup for estimation and immediately brought into the sprint. Necessary team members are assigned as their top priority. -- Impact: High impact. Does not void sprint, but reduces overall velocity and requires deprioritizing other work. +- Critical: `P1` + - Examples: A supported workflow is broken ([critical bug](https://fleetdm.com/handbook/company/product-groups#release-testing)), a potential security vulnerability, a new feature is required to address an immediate critical business need. + - Response: Issue brought to next standup for estimation and immediately brought into the sprint. Necessary team members are assigned as their top priority. + - Impact: High impact. Does not void sprint, but reduces overall velocity and requires deprioritizing other work. -3. Urgent: `P2` -- Examples: A supported workflow is not functioning as intended, a newly drafted feature has an associated urgent business need. -- Response: Issue is prioritized at the top of the next sprint. If opporunity cost of waiting for the next sprint is too high, it may be considered for current sprint. -- Impact: Low to medium impact. If prioritized into current sprint, may reduce overall velocity and require deprioritizing other work. +- Urgent: `P2` + - Examples: A supported workflow is not functioning as intended, a newly drafted feature has an associated urgent business need. + - Response: Issue is prioritized at the top of the next sprint. If opporunity cost of waiting for the next sprint is too high, it may be considered for current sprint. + - Impact: Low to medium impact. If prioritized into current sprint, may reduce overall velocity and require deprioritizing other work. Add as much context as possible to the issue description and assign labels to help the team understand the problem and what is driving the urgency. All issues with a `P0`, `P1`, or `P2` label should be assigned to the [DRI for what goes in a release](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris). For immediate action, follow up on Slack or by phone. From ea86f9ed0f77fb1f7615286edf90a08e3e7cf736 Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Mon, 29 Apr 2024 07:48:35 -0700 Subject: [PATCH 59/83] =?UTF-8?q?UI=20=E2=80=93Restore=20missing=20tooltip?= =?UTF-8?q?s=20(#18598)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changes/18597-missing-tooltips | 2 ++ .../pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changes/18597-missing-tooltips diff --git a/changes/18597-missing-tooltips b/changes/18597-missing-tooltips new file mode 100644 index 0000000000..2aead74598 --- /dev/null +++ b/changes/18597-missing-tooltips @@ -0,0 +1,2 @@ +* Restore missing tooltips when hovering over the disabled "Calendar events" manage automations +dropdown option. diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 2f848e5f13..14b7cc7b4d 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -794,7 +794,7 @@ const ManagePolicyPage = ({ value: "calendar_events", disabled: !isPremiumTier || isAllTeams, helpText: "Automatically reserve time to resolve failing policies.", - disabledTooltipContent, + tooltipContent: disabledTooltipContent, }, { label: "Other workflows", From 0b66bc4a9b1a9f94f52d856ca3ec7f37f42fb729 Mon Sep 17 00:00:00 2001 From: JD Date: Mon, 29 Apr 2024 07:53:04 -0700 Subject: [PATCH 60/83] Add Windows AV policy (#18551) Adds policy and query to verify Windows Defender is installed and working on Windows. https://github.com/fleetdm/fleet/issues/18494 --- .../lib/collect-windows-defender.queries.yml | 10 ++++++++++ it-and-security/lib/windows-device-health.policies.yml | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 it-and-security/lib/collect-windows-defender.queries.yml diff --git a/it-and-security/lib/collect-windows-defender.queries.yml b/it-and-security/lib/collect-windows-defender.queries.yml new file mode 100644 index 0000000000..739dfcd999 --- /dev/null +++ b/it-and-security/lib/collect-windows-defender.queries.yml @@ -0,0 +1,10 @@ +- name: Collect Windows Defender + automations_enabled: false + description: Collects the pid, process name, user, path and command line for Windows Defender installed on hosts. + discard_data: false + interval: 3600 + logging: snapshot + min_osquery_version: "" + observer_can_run: true + platform: "windows" + query: SELECT processes.pid, processes.name, users.username, processes.path, processes.cmdline FROM processes LEFT JOIN users ON processes.uid = users.uid WHERE processes.path != '' AND name LIKE 'MpCmdRun.exe'; \ No newline at end of file diff --git a/it-and-security/lib/windows-device-health.policies.yml b/it-and-security/lib/windows-device-health.policies.yml index 5a15b90cc5..40e276caae 100644 --- a/it-and-security/lib/windows-device-health.policies.yml +++ b/it-and-security/lib/windows-device-health.policies.yml @@ -22,4 +22,10 @@ description: This policy checks if the end user is required to enter a password, with at least 10 characters, to unlock the host. resolution: "As an IT admin, deploy a Windows profile with the DevicePasswordEnabled and MinDevicePasswordLength option documented here: https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-devicelock" platform: windows +- name: Windows - Antivirus healthy + 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; + critical: false + 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." + platform: windows From d0f0d3d017c0a11717b5595e93b74458d8f403cf Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 29 Apr 2024 10:20:59 -0500 Subject: [PATCH 61/83] When updating a policy's 'platform' field, the aggregated policy stats are now cleared. (#18415) #18157 When updating a policy's 'platform' field, the aggregated policy stats are now cleared. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/18157-update-platform-policy-stats | 1 + server/datastore/mysql/policies.go | 176 +++++++++--- server/datastore/mysql/policies_test.go | 320 ++++++++++++++++++++- server/fleet/datastore.go | 2 +- server/mock/datastore_mock.go | 6 +- server/service/global_policies_test.go | 2 +- server/service/integration_core_test.go | 59 ++++ server/service/team_policies.go | 17 +- server/service/team_policies_test.go | 2 +- 9 files changed, 527 insertions(+), 58 deletions(-) create mode 100644 changes/18157-update-platform-policy-stats diff --git a/changes/18157-update-platform-policy-stats b/changes/18157-update-platform-policy-stats new file mode 100644 index 0000000000..fdaa87d56d --- /dev/null +++ b/changes/18157-update-platform-policy-stats @@ -0,0 +1 @@ +When updating a policy's 'platform' field, the aggregated policy stats are now cleared. diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index d80dd2d60b..1ad7ef8f47 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -14,6 +15,7 @@ import ( "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" "github.com/jmoiron/sqlx" ) @@ -110,8 +112,8 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) // SavePolicy updates some fields of the given policy on the datastore. // -// Currently SavePolicy does not allow updating the team of an existing policy. -func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool) error { +// 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 { // We must normalize the name for full Unicode support (Unicode equivalence). p.Name = norm.NFC.String(p.Name) sql := ` @@ -133,10 +135,39 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo return ctxerr.Wrap(ctx, notFound("Policy").WithID(p.ID)) } + return cleanupPolicy(ctx, ds.writer(ctx), p.ID, p.Platform, shouldRemoveAllPolicyMemberships, removePolicyStats, ds.logger) +} + +func cleanupPolicy( + ctx context.Context, extContext sqlx.ExtContext, policyID uint, policyPlatform string, shouldRemoveAllPolicyMemberships bool, + removePolicyStats bool, logger kitlog.Logger, +) error { + var err error if shouldRemoveAllPolicyMemberships { - return ds.cleanupPolicyMembershipForPolicy(ctx, p.ID) + err = cleanupPolicyMembershipForPolicy(ctx, extContext, policyID) + } else { + err = cleanupPolicyMembershipOnPolicyUpdate(ctx, extContext, policyID, policyPlatform) } - return cleanupPolicyMembershipOnPolicyUpdate(ctx, ds.writer(ctx), p.ID, p.Platform) + if err != nil { + return err + } + if removePolicyStats { + // delete all policy stats for the policy + fn := func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, `DELETE FROM policy_stats WHERE policy_id = ?`, policyID) + return err + } + if _, isDB := extContext.(*sqlx.DB); isDB { + // wrapping in a retry to avoid deadlocks with the cleanups_then_aggregation cron job + err = withRetryTxx(ctx, extContext.(*sqlx.DB), fn, logger) + } else { + err = fn(extContext) + } + if err != nil { + return ctxerr.Wrap(ctx, err, "cleanup policy stats") + } + } + return nil } // FlippingPoliciesForHost fetches previous policy membership results and returns: @@ -576,8 +607,74 @@ func (ds *Datastore) TeamPolicy(ctx context.Context, teamID uint, policyID uint) // NOTE: Similar to ApplyQueries, ApplyPolicySpecs will update the author_id of the policies // that are updated. // -// Currently ApplyPolicySpecs does not allow updating the team of an existing policy. +// Currently, ApplyPolicySpecs does not allow updating the team of an existing policy. func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + // Use the same DB for all operations in this method for performance + queryerContext := ds.writer(ctx) + + // Preprocess specs and group them by team + teamNameToID := make(map[string]uint, 1) + teamIDToPolicies := make(map[uint][]*fleet.PolicySpec, 1) + + // Get the team IDs + for _, spec := range specs { + // We must normalize the name for full Unicode support (Unicode equivalence). + spec.Name = norm.NFC.String(spec.Name) + spec.Team = norm.NFC.String(spec.Team) + 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") + } + return ctxerr.Wrap(ctx, err, "get team id") + } + } + teamNameToID[spec.Team] = teamID + } + teamIDToPolicies[teamID] = append(teamIDToPolicies[teamID], spec) + } + + // 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"` + } + 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)) + for _, spec := range teamPolicySpecs { + policyNames = append(policyNames, spec.Name) + } + + 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) + } else { + query, args, err = sqlx.In( + "SELECT name, query, platforms FROM policies WHERE team_id = ? AND name IN (?)", &teamID, policyNames, + ) + } + if err != nil { + return ctxerr.Wrap(ctx, err, "building query to get policies by name") + } + policies := make([]policyLite, 0, len(teamPolicySpecs)) + err = sqlx.SelectContext(ctx, queryerContext, &policies, query, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting policies by name") + } + for _, p := range policies { + teamIDToPoliciesByName[teamID][p.Name] = p + } + } + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { query := fmt.Sprintf( ` @@ -592,7 +689,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs critical, calendar_events_enabled, checksum - ) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, ?, %s) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), @@ -603,24 +700,45 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs calendar_events_enabled = VALUES(calendar_events_enabled) `, policiesChecksumComputedColumn(), ) - for _, spec := range specs { - - // We must normalize the name for full Unicode support (Unicode equivalence). - spec.Name = norm.NFC.String(spec.Name) - res, err := tx.ExecContext(ctx, - query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical, - spec.CalendarEventsEnabled, - ) - if err != nil { - return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") + for teamID, teamPolicySpecs := range teamIDToPolicies { + var teamIDPtr *uint + if teamID != 0 { + teamIDPtr = &teamID } + for _, spec := range teamPolicySpecs { - if insertOnDuplicateDidUpdate(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 { - if err := cleanupPolicyMembershipOnPolicyUpdate(ctx, tx, uint(lastID), spec.Platform); err != nil { - return err + res, err := tx.ExecContext( + ctx, + query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamIDPtr, spec.Platform, spec.Critical, + spec.CalendarEventsEnabled, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") + } + + if insertOnDuplicateDidUpdate(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 { + var ( + shouldRemoveAllPolicyMemberships bool + removePolicyStats bool + ) + // Figure out if the query or platform changed + if prev, ok := teamIDToPoliciesByName[teamID][spec.Name]; ok { + switch { + case prev.Query != spec.Query: + shouldRemoveAllPolicyMemberships = true + removePolicyStats = true + case prev.Platforms != spec.Platform: + removePolicyStats = true + } + } + if err = cleanupPolicy( + ctx, tx, uint(lastID), spec.Platform, shouldRemoveAllPolicyMemberships, removePolicyStats, ds.logger, + ); err != nil { + return err + } } } } @@ -739,7 +857,7 @@ func cleanupPolicyMembershipOnPolicyUpdate(ctx context.Context, db sqlx.ExecerCo // cleanupPolicyMembership is similar to cleanupPolicyMembershipOnPolicyUpdate but without the platform constraints. // Used when we want to remove all policy membership. -func (ds *Datastore) cleanupPolicyMembershipForPolicy(ctx context.Context, policyID uint) error { +func cleanupPolicyMembershipForPolicy(ctx context.Context, exec sqlx.ExecerContext, policyID uint) error { // delete all policy memberships for the policy delStmt := ` DELETE @@ -754,21 +872,11 @@ func (ds *Datastore) cleanupPolicyMembershipForPolicy(ctx context.Context, polic pm.policy_id = ? ` - _, err := ds.writer(ctx).ExecContext(ctx, delStmt, policyID) + _, err := exec.ExecContext(ctx, delStmt, policyID) if err != nil { return ctxerr.Wrap(ctx, err, "cleanup policy membership") } - // delete all policy stats for the policy - // wrapping in a retry to avoid deadlocks with the cleanups_then_aggregation cron job - err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, `DELETE FROM policy_stats WHERE policy_id = ?`, policyID) - return err - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "cleanup policy stats") - } - return nil } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 045d850fa1..90d9015489 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -40,6 +40,7 @@ func TestPolicies(t *testing.T) { {"PoliciesByID", testPoliciesByID}, {"TeamPolicyTransfer", testTeamPolicyTransfer}, {"ApplyPolicySpec", testApplyPolicySpec}, + {"ApplyPolicySpecWithQueryPlatformChanges", testApplyPolicySpecWithQueryPlatformChanges}, {"Save", testPoliciesSave}, {"DelUser", testPoliciesDelUser}, {"FlippingPoliciesForHost", testFlippingPoliciesForHost}, @@ -1400,6 +1401,298 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { })) } +func testApplyPolicySpecWithQueryPlatformChanges(t *testing.T, ds *Datastore) { + ctx := context.Background() + unicode, _ := strconv.Unquote(`"\uAC00"`) // 가 + unicodeEq, _ := strconv.Unquote(`"\u1100\u1161"`) // ᄀ + ᅡ + + user1 := test.NewUser(t, ds, "User1", "user1@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1" + unicode}) + require.NoError(t, err) + + globalNames := []string{"global query1" + unicode, "global query2" + unicode, "global query3" + unicode} + teamNames := []string{"team query1", "team query2", "team query3"} + require.NoError( + t, ds.ApplyPolicySpecs( + ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: globalNames[0], + Query: "select 1;", + Team: "", + Platform: "", + }, + { + Name: globalNames[1], + Query: "select 2;", + Team: "", + Platform: "darwin", + }, + { + Name: globalNames[2], + Query: "select 3;", + Team: "", + Platform: "darwin,linux", + }, + { + Name: teamNames[0], + Query: "select 1;", + Team: "team1" + unicode, + Platform: "", + }, + { + Name: teamNames[1], + Query: "select 2;", + Team: "team1" + unicode, + Platform: "darwin", + }, + { + Name: teamNames[2], + Query: "select 3;", + Team: "team1" + unicodeEq, + Platform: "darwin,linux", + }, + }, + ), + ) + + // create hosts with different platforms, for that team + const hostWin, hostMac, hostDeb, hostLin = 0, 1, 2, 3 + platforms := []string{"windows", "darwin", "debian", "linux"} + teamHosts := make([]*fleet.Host, len(platforms)) + for i, pl := range platforms { + id := fmt.Sprintf("%s-%d", strings.ReplaceAll(t.Name(), "/", "_"), i) + h, err := ds.NewHost( + ctx, &fleet.Host{ + OsqueryHostID: &id, + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: &id, + UUID: id, + Hostname: id, + Platform: pl, + TeamID: ptr.Uint(team1.ID), + }, + ) + require.NoError(t, err) + teamHosts[i] = h + } + + // create hosts with different platforms, without team + globalHosts := make([]*fleet.Host, len(platforms)) + for i, pl := range platforms { + id := fmt.Sprintf("g%s-%d", strings.ReplaceAll(t.Name(), "/", "_"), i) + h, err := ds.NewHost( + ctx, &fleet.Host{ + OsqueryHostID: &id, + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: &id, + UUID: id, + Hostname: id, + Platform: pl, + }, + ) + require.NoError(t, err) + globalHosts[i] = h + } + + // load the global policies + gPolicies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, gPolicies, 3) + // load the team policies + tPolicies, _, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tPolicies, 3) + + // index the policies by name for easier access in the rest of the test + polsByName := make(map[string]*fleet.Policy, len(gPolicies)+len(tPolicies)) + globalPolsByName := make(map[string]*fleet.Policy, len(gPolicies)) + for _, pol := range tPolicies { + polsByName[pol.Name] = pol + } + for _, pol := range gPolicies { + globalPolsByName[pol.Name] = pol + polsByName[pol.Name] = pol + } + + // record some results for each policy + // Note: we are adding results to hosts that shouldn't have results, based on their platform. + for _, h := range teamHosts { + res := make(map[uint]*bool, len(polsByName)) + for _, pol := range polsByName { + res[pol.ID] = ptr.Bool(false) + } + err = ds.RecordPolicyQueryExecutions(ctx, h, res, time.Now(), false) + require.NoError(t, err) + } + for _, h := range globalHosts { + res := make(map[uint]*bool, len(globalPolsByName)) + for _, pol := range globalPolsByName { + res[pol.ID] = ptr.Bool(false) + } + err = ds.RecordPolicyQueryExecutions(ctx, h, res, time.Now(), false) + require.NoError(t, err) + } + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + + // Update host failure counts and ensure they are correct + teamHosts, err = ds.UpdatePolicyFailureCountsForHosts(ctx, teamHosts) + require.NoError(t, err) + assert.Equal(t, 6, teamHosts[hostWin].FailingPoliciesCount) + assert.Equal(t, 6, teamHosts[hostMac].FailingPoliciesCount) + assert.Equal(t, 6, teamHosts[hostDeb].FailingPoliciesCount) + assert.Equal(t, 6, teamHosts[hostLin].FailingPoliciesCount) + globalHosts, err = ds.UpdatePolicyFailureCountsForHosts(ctx, globalHosts) + require.NoError(t, err) + assert.Equal(t, 3, globalHosts[hostWin].FailingPoliciesCount) + assert.Equal(t, 3, globalHosts[hostMac].FailingPoliciesCount) + assert.Equal(t, 3, globalHosts[hostDeb].FailingPoliciesCount) + assert.Equal(t, 3, globalHosts[hostLin].FailingPoliciesCount) + + // Ensure policy passing and failing counts are correct + gPolicies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, gPolicies, 3) + tPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tPolicies, 3) + + for _, pol := range gPolicies { + polsByName[pol.Name] = pol + } + for _, pol := range tPolicies { + polsByName[pol.Name] = pol + } + assert.Equal(t, uint(8), polsByName[globalNames[0]].FailingHostCount) + assert.Equal(t, uint(8), polsByName[globalNames[1]].FailingHostCount) + assert.Equal(t, uint(8), polsByName[globalNames[2]].FailingHostCount) + assert.Equal(t, uint(4), polsByName[teamNames[0]].FailingHostCount) + assert.Equal(t, uint(4), polsByName[teamNames[1]].FailingHostCount) + assert.Equal(t, uint(4), polsByName[teamNames[2]].FailingHostCount) + + // Update policies + require.NoError( + t, ds.ApplyPolicySpecs( + ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: globalNames[0], + Query: "select 1;", + Team: "", + Platform: "", + Description: "updated", // update description + }, + { + Name: globalNames[1], + Query: "select 2 updated;", // update query + Team: "", + Platform: "darwin", + }, + { + Name: globalNames[2], + Query: "select 3;", + Team: "", + Platform: "darwin", // update platform + }, + { + Name: "new global query", + Query: "select 4;", + Team: "", + Platform: "", + }, + { + Name: teamNames[0], + Query: "select 1;", + Team: "team1" + unicode, + Platform: "linux", // update platform + }, + { + Name: teamNames[1], + Query: "select 2;", + Team: "team1" + unicode, + Platform: "darwin", + CalendarEventsEnabled: true, // update calendar events + }, + { + Name: teamNames[2], + Query: "select 3 updated;", // update query + Team: "team1" + unicodeEq, + Platform: "darwin,linux", + }, + { + Name: "new team query", + Query: "select 4;", + Team: "team1" + unicode, + Platform: "", + }, + }, + ), + ) + + // Update host failure counts and ensure they are correct + teamHosts, err = ds.UpdatePolicyFailureCountsForHosts(ctx, teamHosts) + require.NoError(t, err) + assert.Equal(t, 1, teamHosts[hostWin].FailingPoliciesCount) // kept result from globalNames[0] + assert.Equal(t, 3, teamHosts[hostMac].FailingPoliciesCount) + assert.Equal(t, 2, teamHosts[hostDeb].FailingPoliciesCount) + assert.Equal(t, 2, teamHosts[hostLin].FailingPoliciesCount) + globalHosts, err = ds.UpdatePolicyFailureCountsForHosts(ctx, globalHosts) + require.NoError(t, err) + assert.Equal(t, 1, globalHosts[hostWin].FailingPoliciesCount) + assert.Equal(t, 2, globalHosts[hostMac].FailingPoliciesCount) + assert.Equal(t, 1, globalHosts[hostDeb].FailingPoliciesCount) + assert.Equal(t, 1, globalHosts[hostLin].FailingPoliciesCount) + + // Ensure policy passing and failing counts are correct + gPolicies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, gPolicies, 4) + tPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tPolicies, 4) + + for _, pol := range gPolicies { + polsByName[pol.Name] = pol + } + for _, pol := range tPolicies { + polsByName[pol.Name] = pol + } + assert.Equal(t, uint(8), polsByName[globalNames[0]].FailingHostCount) + assert.Equal(t, uint(0), polsByName[globalNames[1]].FailingHostCount) // updated query + assert.Equal(t, uint(0), polsByName[globalNames[2]].FailingHostCount) // updated platform + assert.Equal(t, uint(0), polsByName[teamNames[0]].FailingHostCount) // updated platform + assert.Equal(t, uint(4), polsByName[teamNames[1]].FailingHostCount) + assert.Equal(t, uint(0), polsByName[teamNames[2]].FailingHostCount) // updated query + + err = ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + gPolicies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, gPolicies, 4) + tPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, tPolicies, 4) + + for _, pol := range gPolicies { + polsByName[pol.Name] = pol + } + for _, pol := range tPolicies { + polsByName[pol.Name] = pol + } + assert.Equal(t, uint(8), polsByName[globalNames[0]].FailingHostCount) // platform is "" -- no change + assert.Equal(t, uint(0), polsByName[globalNames[1]].FailingHostCount) // updated query + assert.Equal(t, uint(2), polsByName[globalNames[2]].FailingHostCount) // updated platform + assert.Equal(t, uint(2), polsByName[teamNames[0]].FailingHostCount) // updated platform + assert.Equal(t, uint(1), polsByName[teamNames[1]].FailingHostCount) // platform is "darwin" -- no change + assert.Equal(t, uint(0), polsByName[teamNames[2]].FailingHostCount) // updated query + +} + func testPoliciesSave(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "User1", "user1@example.com", true) ctx := context.Background() @@ -1412,7 +1705,8 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { Name: "non-existent query", Query: "select 1;", }, - }, false) + }, false, false, + ) require.Error(t, err) var nfe *notFoundError require.True(t, errors.As(err, &nfe)) @@ -1473,7 +1767,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { gp2 := *gp gp2.Name = "global query updated" gp2.Critical = true - err = ds.SavePolicy(ctx, &gp2, false) + err = ds.SavePolicy(ctx, &gp2, false, false) require.NoError(t, err) gp, err = ds.Policy(ctx, gp.ID) require.NoError(t, err) @@ -1493,7 +1787,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { tp2.Resolution = ptr.String("team1 query resolution updated") tp2.Critical = false tp2.CalendarEventsEnabled = false - err = ds.SavePolicy(ctx, &tp2, true) + err = ds.SavePolicy(ctx, &tp2, true, true) require.NoError(t, err) tp1, err = ds.Policy(ctx, tp1.ID) tp2.UpdateCreateTimestamps = tp1.UpdateCreateTimestamps @@ -1586,7 +1880,7 @@ func testCachedPolicyCountDeletesOnPolicyChange(t *testing.T, ds *Datastore) { assert.Equal(t, uint(1), inheritedPolicies[0].PassingHostCount) // Update the global policy sql to trigger a cache invalidation - err = ds.SavePolicy(ctx, globalPolicy, true) + err = ds.SavePolicy(ctx, globalPolicy, true, true) require.NoError(t, err) globalPolicy, err = ds.Policy(ctx, globalPolicy.ID) @@ -1599,8 +1893,8 @@ func testCachedPolicyCountDeletesOnPolicyChange(t *testing.T, ds *Datastore) { assert.Equal(t, uint(1), teamPolicies[0].PassingHostCount) assert.Equal(t, uint(0), inheritedPolicies[0].PassingHostCount) - // Update the team policy sql to trigger a cache invalidation - err = ds.SavePolicy(ctx, teamPolicy, true) + // Update the team policy platform to trigger a cache invalidation + err = ds.SavePolicy(ctx, teamPolicy, false, true) require.NoError(t, err) teamPolicies, inheritedPolicies, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) @@ -1921,9 +2215,9 @@ func testPolicyPlatformUpdate(t *testing.T, ds *Datastore) { } // updating without change works fine - err = ds.SavePolicy(ctx, polsByName["g1"], false) + err = ds.SavePolicy(ctx, polsByName["g1"], false, false) require.NoError(t, err) - err = ds.SavePolicy(ctx, polsByName["t2"], false) + err = ds.SavePolicy(ctx, polsByName["t2"], false, false) require.NoError(t, err) // apply specs that result in an update (without change) works fine err = ds.ApplyPolicySpecs(ctx, user.ID, []*fleet.PolicySpec{ @@ -1975,7 +2269,7 @@ func testPolicyPlatformUpdate(t *testing.T, ds *Datastore) { g1 := polsByName["g1"] g1.Platform = "linux" polsByName["g1"] = g1 - err = ds.SavePolicy(ctx, g1, false) + err = ds.SavePolicy(ctx, g1, false, false) require.NoError(t, err) wantHostsByPol["g1"] = []uint{globalHosts[hostDeb].ID, globalHosts[hostLin].ID} assertPolicyMembership(t, ds, polsByName, wantHostsByPol) @@ -1984,7 +2278,7 @@ func testPolicyPlatformUpdate(t *testing.T, ds *Datastore) { t1 := polsByName["t1"] t1.Platform = "windows,darwin" polsByName["t1"] = t1 - err = ds.SavePolicy(ctx, t1, false) + err = ds.SavePolicy(ctx, t1, false, false) require.NoError(t, err) wantHostsByPol["t1"] = []uint{teamHosts[hostWin].ID, teamHosts[hostMac].ID} assertPolicyMembership(t, ds, polsByName, wantHostsByPol) @@ -2723,7 +3017,7 @@ func testPoliciesNameUnicode(t *testing.T, ds *Datastore) { policyEmoji, err := ds.NewGlobalPolicy(context.Background(), nil, fleet.PolicyPayload{Name: "💻"}) require.NoError(t, err) err = ds.SavePolicy( - context.Background(), &fleet.Policy{PolicyData: fleet.PolicyData{ID: policyEmoji.ID, Name: equivalentNames[1]}}, false, + context.Background(), &fleet.Policy{PolicyData: fleet.PolicyData{ID: policyEmoji.ID, Name: equivalentNames[1]}}, false, false, ) assert.True(t, isDuplicate(err), err) @@ -3270,10 +3564,10 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { // team2Policy1.Platform = "darwin" - err = ds.SavePolicy(ctx, team1Policy1, false) + err = ds.SavePolicy(ctx, team1Policy1, false, true) require.NoError(t, err) team1Policy1.Platform = "darwin" - err = ds.SavePolicy(ctx, team2Policy1, false) + err = ds.SavePolicy(ctx, team2Policy1, false, true) require.NoError(t, err) // diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 764552802c..f8cc5050ca 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -602,7 +602,7 @@ type Datastore interface { // SavePolicy updates some fields of the given policy on the datastore. // // It is also used to update team policies. - SavePolicy(ctx context.Context, p *Policy, shouldRemoveAllPolicyMemberships bool) error + SavePolicy(ctx context.Context, p *Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error ListGlobalPolicies(ctx context.Context, opts ListOptions) ([]*Policy, error) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*Policy, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index a1211c4f74..07260d7d03 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -435,7 +435,7 @@ type NewGlobalPolicyFunc func(ctx context.Context, authorID *uint, args fleet.Po type PolicyFunc func(ctx context.Context, id uint) (*fleet.Policy, error) -type SavePolicyFunc func(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool) error +type SavePolicyFunc func(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error type ListGlobalPoliciesFunc func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) @@ -3729,11 +3729,11 @@ func (s *DataStore) Policy(ctx context.Context, id uint) (*fleet.Policy, error) return s.PolicyFunc(ctx, id) } -func (s *DataStore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool) error { +func (s *DataStore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error { s.mu.Lock() s.SavePolicyFuncInvoked = true s.mu.Unlock() - return s.SavePolicyFunc(ctx, p, shouldRemoveAllPolicyMemberships) + return s.SavePolicyFunc(ctx, p, shouldRemoveAllPolicyMemberships, removePolicyStats) } func (s *DataStore) ListGlobalPolicies(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { diff --git a/server/service/global_policies_test.go b/server/service/global_policies_test.go index 615c0600f4..108de7c70b 100644 --- a/server/service/global_policies_test.go +++ b/server/service/global_policies_test.go @@ -68,7 +68,7 @@ func TestGlobalPoliciesAuth(t *testing.T) { ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return nil } - ds.SavePolicyFunc = func(ctx context.Context, p *fleet.Policy, shouldDeleteAll bool) error { + ds.SavePolicyFunc = func(ctx context.Context, p *fleet.Policy, shouldDeleteAll bool, removePolicyStats bool) error { return nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 88742e44df..b87b228ab5 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -2428,6 +2428,65 @@ func (s *integrationTestSuite) TestGlobalPoliciesProprietary() { assert.Equal(t, uint(0), policiesResponse.Policies[0].FailingHostCount) assert.Equal(t, uint(0), policiesResponse.Policies[0].PassingHostCount) + // Record query executions + require.NoError( + t, s.ds.RecordPolicyQueryExecutions( + context.Background(), h1.Host, map[uint]*bool{policiesResponse.Policies[0].ID: ptr.Bool(true)}, time.Now(), false, + ), + ) + require.NoError( + t, s.ds.RecordPolicyQueryExecutions( + context.Background(), h2.Host, map[uint]*bool{policiesResponse.Policies[0].ID: nil}, time.Now(), false, + ), + ) + // Update policy stats + require.NoError(t, s.ds.UpdateHostPolicyCounts(context.Background())) + + // Fetch policy to make sure stats are updated + s.DoJSON("GET", "/api/latest/fleet/policies", nil, http.StatusOK, &policiesResponse) + require.Len(t, policiesResponse.Policies, 1) + assert.Equal(t, uint(0), policiesResponse.Policies[0].FailingHostCount) + assert.Equal(t, uint(1), policiesResponse.Policies[0].PassingHostCount) + + listHostsURL = fmt.Sprintf("/api/latest/fleet/hosts?policy_id=%d&policy_response=passing", policiesResponse.Policies[0].ID) + listHostsResp = listHostsResponse{} + s.DoJSON("GET", listHostsURL, nil, http.StatusOK, &listHostsResp) + require.Len(t, listHostsResp.Hosts, 1) + + // Modify the platform for the policy, which should clear the policy stats + mgpParams = modifyGlobalPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + Platform: ptr.String("linux"), + }, + } + mgpResp = modifyGlobalPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/policies/%d", gpResp.Policy.ID), mgpParams, http.StatusOK, &mgpResp) + require.NotNil(t, gpResp.Policy) + assert.Equal(t, "TestQuery4", mgpResp.Policy.Name) + assert.Equal(t, "select * from users;", mgpResp.Policy.Query) + assert.Equal(t, "Some description updated", mgpResp.Policy.Description) + require.NotNil(t, mgpResp.Policy.Resolution) + assert.Equal(t, "some global resolution updated", *mgpResp.Policy.Resolution) + assert.Equal(t, "linux", mgpResp.Policy.Platform) + assert.Equal(t, uint(0), mgpResp.Policy.FailingHostCount) + assert.Equal(t, uint(0), mgpResp.Policy.PassingHostCount) + + // Fetch policy to make sure stats are updated + s.DoJSON("GET", "/api/latest/fleet/policies", nil, http.StatusOK, &policiesResponse) + require.Len(t, policiesResponse.Policies, 1) + assert.Equal(t, uint(0), policiesResponse.Policies[0].FailingHostCount) + assert.Equal(t, uint(0), policiesResponse.Policies[0].PassingHostCount) + + listHostsURL = fmt.Sprintf("/api/latest/fleet/hosts?policy_id=%d&policy_response=passing", policiesResponse.Policies[0].ID) + listHostsResp = listHostsResponse{} + s.DoJSON("GET", listHostsURL, nil, http.StatusOK, &listHostsResp) + require.Len(t, listHostsResp.Hosts, 0) + + listHostsURL = fmt.Sprintf("/api/latest/fleet/hosts?policy_id=%d&policy_response=failing", policiesResponse.Policies[0].ID) + listHostsResp = listHostsResponse{} + s.DoJSON("GET", listHostsURL, nil, http.StatusOK, &listHostsResp) + require.Len(t, listHostsResp.Hosts, 0) + deletePolicyParams := deleteGlobalPoliciesRequest{IDs: []uint{policiesResponse.Policies[0].ID}} deletePolicyResp := deleteGlobalPoliciesResponse{} s.DoJSON("POST", "/api/latest/fleet/policies/delete", deletePolicyParams, http.StatusOK, &deletePolicyResp) diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 7786c7fe68..75cbe3ae96 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -368,7 +368,8 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f }) } - var shouldRemoveAll bool + var removeAllMemberships bool + var removeStats bool if p.Name != nil { policy.Name = *p.Name } @@ -377,9 +378,8 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f } if p.Query != nil { if policy.Query != *p.Query { - shouldRemoveAll = true - policy.FailingHostCount = 0 - policy.PassingHostCount = 0 + removeAllMemberships = true + removeStats = true } policy.Query = *p.Query } @@ -387,6 +387,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f policy.Resolution = p.Resolution } if p.Platform != nil { + if policy.Platform != *p.Platform { + removeStats = true + } policy.Platform = *p.Platform } if p.Critical != nil { @@ -395,9 +398,13 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.CalendarEventsEnabled != nil { policy.CalendarEventsEnabled = *p.CalendarEventsEnabled } + if removeStats { + policy.FailingHostCount = 0 + policy.PassingHostCount = 0 + } logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) - err = svc.ds.SavePolicy(ctx, policy, shouldRemoveAll) + err = svc.ds.SavePolicy(ctx, policy, removeAllMemberships, removeStats) if err != nil { return nil, ctxerr.Wrap(ctx, err, "saving policy") } diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index e6079f1101..9e1a502f67 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -44,7 +44,7 @@ func TestTeamPoliciesAuth(t *testing.T) { } return nil, nil } - ds.SavePolicyFunc = func(ctx context.Context, p *fleet.Policy, shouldDeleteAll bool) error { + ds.SavePolicyFunc = func(ctx context.Context, p *fleet.Policy, shouldDeleteAll bool, removePolicyStats bool) error { return nil } ds.DeleteTeamPoliciesFunc = func(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { From fa9373a68f73e7961c300018ea7dfc1649de861b Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 29 Apr 2024 10:54:50 -0500 Subject: [PATCH 62/83] Updated host_operating_system INSERT. (#18556) #16562 Updated MySQL host_operating_system insert statement to reduce table lock time. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [ ] Added/updated tests - Existing tests provide full coverage of the changes. - [x] Manual QA for all new/changed functionality --- changes/16562-deadlock | 1 + server/datastore/mysql/operating_systems.go | 55 ++++++++++++++------- 2 files changed, 39 insertions(+), 17 deletions(-) create mode 100644 changes/16562-deadlock diff --git a/changes/16562-deadlock b/changes/16562-deadlock new file mode 100644 index 0000000000..16675fd8c5 --- /dev/null +++ b/changes/16562-deadlock @@ -0,0 +1 @@ +Updated MySQL host_operating_system insert statement to reduce table lock time and optimize performance for the common case. diff --git a/server/datastore/mysql/operating_systems.go b/server/datastore/mysql/operating_systems.go index 4b9fd0a2d6..b1d8e295d2 100644 --- a/server/datastore/mysql/operating_systems.go +++ b/server/datastore/mysql/operating_systems.go @@ -36,6 +36,15 @@ func (ds *Datastore) ListOperatingSystemsForPlatform(ctx context.Context, platfo } func (ds *Datastore) UpdateHostOperatingSystem(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error { + // We optimize for the most common case where the operating system for the host has not changed. + // No DB transaction or DB write is needed in this case. + updateNeeded, err := isHostOperatingSystemUpdateNeeded(ctx, ds.reader(ctx), hostID, hostOS) + if err != nil { + return err + } + if !updateNeeded { + return nil + } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { os, err := getOrGenerateOperatingSystemDB(ctx, tx, hostOS) if err != nil { @@ -136,26 +145,38 @@ func getOperatingSystemDB(ctx context.Context, tx sqlx.ExtContext, hostOS fleet. return &os, nil } +func isHostOperatingSystemUpdateNeeded(ctx context.Context, qc sqlx.QueryerContext, hostID uint, hostOS fleet.OperatingSystem) ( + bool, error, +) { + var resultPresent bool + err := sqlx.GetContext( + ctx, qc, &resultPresent, + `SELECT 1 FROM host_operating_system hos + INNER JOIN operating_systems os ON hos.os_id = os.id + WHERE hos.host_id = ? AND os.name = ? AND os.version = ? AND os.arch = ? AND os.kernel_version = ? AND os.platform = ? AND os.display_version = ?`, + hostID, hostOS.Name, hostOS.Version, hostOS.Arch, hostOS.KernelVersion, hostOS.Platform, hostOS.DisplayVersion, + ) + switch { + case errors.Is(err, sql.ErrNoRows): + return true, nil + case err != nil: + return false, ctxerr.Wrap(ctx, err, "check host operating system") + default: + return !resultPresent, nil + } +} + // upsertHostOperatingSystemDB upserts the host operating system table // with the operating system id for the given host ID func upsertHostOperatingSystemDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, osID uint) error { - res, err := tx.ExecContext(ctx, "UPDATE host_operating_system SET os_id = ? WHERE host_id = ?", osID, hostID) - if err != nil { - return err - } - - if n, _ := res.RowsAffected(); n > 0 { - // update success - return nil - } - - // no row to update so insert new row - _, err = tx.ExecContext(ctx, "INSERT INTO host_operating_system (host_id, os_id) VALUES (?, ?)", hostID, osID) - if err != nil { - return err - } - - return nil + // We do not use the `UPDATE` then `INSERT` pattern here because it causes a deadlock when multiple hosts are enrolled concurrently. + // This method will rarely be called -- only when the host_operating_system needs to be updated. + _, err := tx.ExecContext( + ctx, + `INSERT INTO host_operating_system (host_id, os_id) VALUES (?, ?) + ON DUPLICATE KEY UPDATE os_id = VALUES(os_id)`, hostID, osID, + ) + return err } // getIDHostOperatingSystemDB queries the `host_operating_system` table and returns the From 51f8e1de67a793f6aa2f6ad4d106660b155c6159 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 29 Apr 2024 10:55:37 -0500 Subject: [PATCH 63/83] fleetd-chrome 1.3.0 release (#18546) #18466 The beta release is already available at: https://chrome-beta.fleetdm.com/updates.xml --- Makefile | 4 +++- ee/fleetd-chrome/CHANGELOG.md | 6 ++++++ ee/fleetd-chrome/README.md | 8 ++++++++ ee/fleetd-chrome/changes/18337-runtime-error | 1 - ee/fleetd-chrome/package-lock.json | 4 ++-- ee/fleetd-chrome/package.json | 2 +- ee/fleetd-chrome/updates-beta.xml | 2 +- ee/fleetd-chrome/updates.xml | 2 +- 8 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 ee/fleetd-chrome/CHANGELOG.md delete mode 100644 ee/fleetd-chrome/changes/18337-runtime-error diff --git a/Makefile b/Makefile index 226410def1..8547479853 100644 --- a/Makefile +++ b/Makefile @@ -325,7 +325,9 @@ changelog-orbit: sh -c "git rm orbit/changes/*" changelog-chrome: - sh -c "find ee/fleetd-chrome/changes -type file | grep -v .keep | xargs -I {} sh -c 'grep \"\S\" {}; echo' > new-CHANGELOG.md" + $(eval TODAY_DATE := $(shell date "+%b %d, %Y")) + @echo -e "## fleetd-chrome $(version) ($(TODAY_DATE))\n" > new-CHANGELOG.md + sh -c "find ee/fleetd-chrome/changes -type file | grep -v .keep | xargs -I {} sh -c 'grep \"\S\" {}; echo' >> new-CHANGELOG.md" sh -c "cat new-CHANGELOG.md ee/fleetd-chrome/CHANGELOG.md > tmp-CHANGELOG.md && rm new-CHANGELOG.md && mv tmp-CHANGELOG.md ee/fleetd-chrome/CHANGELOG.md" sh -c "git rm ee/fleetd-chrome/changes/*" diff --git a/ee/fleetd-chrome/CHANGELOG.md b/ee/fleetd-chrome/CHANGELOG.md new file mode 100644 index 0000000000..41f70d9d4b --- /dev/null +++ b/ee/fleetd-chrome/CHANGELOG.md @@ -0,0 +1,6 @@ +## fleetd-chrome 1.3.0 (Apr 29, 2024) + +* Created a fix to recover after a rare RuntimeError coming from sqlite web assembly code by reinitializing the DB. + +* Fixed a bug where values not derived from "actual" fleetd-chrome tables were not being displayed + correctly (e.g., `SELECT 1` gets its value from the query itself, not a table) diff --git a/ee/fleetd-chrome/README.md b/ee/fleetd-chrome/README.md index c2f117972f..f4486488cd 100644 --- a/ee/fleetd-chrome/README.md +++ b/ee/fleetd-chrome/README.md @@ -67,6 +67,14 @@ npm run test ## Release +1. Update CHANGELOG.md by running `version="X.X.X" make changelog-chrome` +2. Review CHANGELOG.md +3. Run `npm version X.X.X` to update the version in `package.json` and `package-lock.json` +4. Update [updates.xml](./updates.xml) and [updates-beta.xml](./updates-beta.xml) versions. +5. Commit the changes and tag the commit with `fleetd-chrome-vX.X.X-beta`. This will trigger the beta release workflow. +6. Once the beta release is tested and PR merged, tag the commit with `fleetd-chrome-vX.X.X`. This will trigger the release workflow. +7. Announce the release in the #help-engineering channel in Slack. + Release a new version via GitHub automation. Update the [package.json](./package.json) and [updates.xml](./updates.xml) versions, then tag a commit with `fleetd-chrome-vX.X.X` to kick off the build and deploy. The build is automatically uploaded to R2 and properly configured clients should be able to update immediately when the job completes. Note that automatic updates seem to only happen about once a day in Chrome -- Hit the "Update" button in `chrome://extensions` to trigger the update manually. ### Beta releases diff --git a/ee/fleetd-chrome/changes/18337-runtime-error b/ee/fleetd-chrome/changes/18337-runtime-error deleted file mode 100644 index e2e6a71b05..0000000000 --- a/ee/fleetd-chrome/changes/18337-runtime-error +++ /dev/null @@ -1 +0,0 @@ -Reinitialize DB and recover after a rare RuntimeError coming from sqlite web assembly code. diff --git a/ee/fleetd-chrome/package-lock.json b/ee/fleetd-chrome/package-lock.json index 561d034d52..7d4e507720 100644 --- a/ee/fleetd-chrome/package-lock.json +++ b/ee/fleetd-chrome/package-lock.json @@ -1,12 +1,12 @@ { "name": "fleetd-for-chrome", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fleetd-for-chrome", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { "dotenv": "^16.0.3", "wa-sqlite": "github:rhashimoto/wa-sqlite#v0.9.11" diff --git a/ee/fleetd-chrome/package.json b/ee/fleetd-chrome/package.json index eba4f66075..18e6dfe634 100644 --- a/ee/fleetd-chrome/package.json +++ b/ee/fleetd-chrome/package.json @@ -1,7 +1,7 @@ { "name": "fleetd-for-chrome", "description": "Extension for Fleetd on ChromeOS", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { "dotenv": "^16.0.3", "wa-sqlite": "github:rhashimoto/wa-sqlite#v0.9.11" diff --git a/ee/fleetd-chrome/updates-beta.xml b/ee/fleetd-chrome/updates-beta.xml index fdb6c2e6fd..90bbeda472 100644 --- a/ee/fleetd-chrome/updates-beta.xml +++ b/ee/fleetd-chrome/updates-beta.xml @@ -1,6 +1,6 @@ - + diff --git a/ee/fleetd-chrome/updates.xml b/ee/fleetd-chrome/updates.xml index 7bf2b24b2c..1e07bdc864 100644 --- a/ee/fleetd-chrome/updates.xml +++ b/ee/fleetd-chrome/updates.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file From 895d4bc2f7d3b885f315aabfc9b58546d6d73e0a Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Mon, 29 Apr 2024 11:12:03 -0500 Subject: [PATCH 64/83] Use "Fleet's agent (fleetd)" naming in the docs (#18138) For #16512 --------- Co-authored-by: Eric --- docs/Configuration/agent-configuration.md | 12 +++---- .../fleet-server-configuration.md | 8 ++--- docs/Contributing/File-carving.md | 6 ++-- docs/Deploy/deploy-on-render.md | 2 +- docs/Deploy/public-ip.md | 2 +- docs/Get started/FAQ.md | 8 ++--- docs/Get started/anatomy.md | 2 +- docs/REST API/rest-api.md | 9 +++-- docs/Using Fleet/Learn-how-to-use-Fleet.md | 6 ++-- .../Supported-host-operating-systems.md | 6 ++-- docs/Using Fleet/enroll-hosts.md | 36 +++++++++---------- docs/Using Fleet/fleetctl-CLI.md | 4 +-- docs/Using Fleet/update-agents.md | 6 ++-- schema/tables/apfs_physical_stores.yml | 2 +- schema/tables/apfs_volumes.yml | 2 +- schema/tables/authdb.yml | 2 +- schema/tables/cis_audit.yml | 2 +- .../corestorage_logical_volume_families.yml | 2 +- schema/tables/corestorage_logical_volumes.yml | 2 +- schema/tables/csrutil_info.yml | 2 +- schema/tables/dscl.yml | 2 +- schema/tables/file_lines.yml | 2 +- schema/tables/filevault_prk.yml | 2 +- schema/tables/filevault_users.yml | 2 +- schema/tables/find_cmd.yml | 4 +-- .../firmware_eficheck_integrity_check.yml | 2 +- schema/tables/google_chrome_profiles.yml | 2 +- schema/tables/icloud_private_relay.yml | 2 +- schema/tables/macadmins_unified_log.yml | 2 +- schema/tables/macos_profiles.yml | 2 +- schema/tables/macos_rsr.yml | 2 +- schema/tables/mdm.yml | 7 +++- schema/tables/mdm_bridge.yml | 2 +- schema/tables/munki_info.yml | 5 ++- schema/tables/munki_installs.yml | 5 ++- schema/tables/nvram_info.yml | 2 +- schema/tables/orbit_info.yml | 2 +- schema/tables/parse_ini.yml | 2 +- schema/tables/parse_json.yml | 2 +- schema/tables/parse_jsonl.yml | 2 +- schema/tables/parse_xml.yml | 2 +- schema/tables/pmset.yml | 4 +-- schema/tables/puppet_info.yml | 2 +- schema/tables/puppet_logs.yml | 2 +- schema/tables/puppet_state.yml | 2 +- schema/tables/pwd_policy.yml | 4 +-- schema/tables/sntp_request.yml | 4 +-- schema/tables/software_update.yml | 2 +- schema/tables/sudo_info.yml | 2 +- schema/tables/user_login_settings.yml | 2 +- 50 files changed, 101 insertions(+), 99 deletions(-) diff --git a/docs/Configuration/agent-configuration.md b/docs/Configuration/agent-configuration.md index 78ad27398f..41569959ee 100644 --- a/docs/Configuration/agent-configuration.md +++ b/docs/Configuration/agent-configuration.md @@ -25,7 +25,7 @@ If you are not using the latest version of osquery, you can create a config YAML fleetctl apply --force -f config.yaml ``` -You can verify that your agent options are valid by using [the fleetctl apply command](https://fleetdm.com/docs/using-fleet/fleetctl-cli#fleetctl-apply) with the `--dry-run` flag. This will report any error and do nothing if the configuration was valid. If you don't use the latest version of osquery, you can override validation using the `--force` flag. This will update agent options even if they are invalid. +You can verify that your agent options are valid by using [the `fleetctl apply` command](https://fleetdm.com/docs/using-fleet/fleetctl-cli) with the `--dry-run` flag. This will report any error and do nothing if the configuration was valid. If you don't use the latest version of osquery, you can override validation using the `--force` flag. This will update agent options even if they are invalid. Existing options will be overwritten by the application of this file. @@ -132,7 +132,7 @@ apiVersion: v1 kind: config spec: agent_options: - command_line_flags: # requires Fleet's osquery installer + command_line_flags: # requires Fleet's agent (fleetd) verbose: true disable_watchdog: false logger_path: /path/to/logger @@ -186,7 +186,7 @@ apiVersion: v1 kind: config spec: agent_options: - extensions: # requires Fleet's osquery installer + extensions: # requires Fleet's agent (fleetd) hello_world_macos: channel: 'stable' platform: 'macos' @@ -252,7 +252,7 @@ apiVersion: v1 kind: config spec: agent_options: - extensions: # requires Fleet's osquery installer + extensions: # requires Fleet's agent (fleetd) hello_world_macos: channel: 'stable' platform: 'macos' @@ -284,7 +284,7 @@ apiVersion: v1 kind: config spec: agent_options: - update_channels: # requires Fleet's osquery installer + update_channels: # requires Fleet's agent (fleetd) orbit: stable osqueryd: '5.10.2' desktop: edge @@ -294,7 +294,7 @@ apiVersion: v1 kind: config spec: agent_options: - update_channels: # requires Fleet's osquery installer + update_channels: # requires Fleet's agent (fleetd) orbit: edge osqueryd: '5.10.2' # in this configuration `desktop` is assumed to be "stable" diff --git a/docs/Configuration/fleet-server-configuration.md b/docs/Configuration/fleet-server-configuration.md index 248b44617c..0bb022f8c5 100644 --- a/docs/Configuration/fleet-server-configuration.md +++ b/docs/Configuration/fleet-server-configuration.md @@ -897,7 +897,7 @@ This flag can be used to control load on the database in scenarios in which many ##### osquery_label_update_interval -The interval at which Fleet will ask osquery agents to update their results for label queries. +The interval at which Fleet will ask Fleet's agent (fleetd) to update results for label queries. Setting this to a higher value can reduce baseline load on the Fleet server in larger deployments. @@ -915,7 +915,7 @@ Valid time units are `s`, `m`, `h`. ##### osquery_policy_update_interval -The interval at which Fleet will ask osquery agents to update their results for policy queries. +The interval at which Fleet will ask Fleet's agent (fleetd) to update results for policy queries. Setting this to a higher value can reduce baseline load on the Fleet server in larger deployments. @@ -933,7 +933,7 @@ Valid time units are `s`, `m`, `h`. ##### osquery_detail_update_interval -The interval at which Fleet will ask osquery agents to update host details (such as uptime, hostname, network interfaces, etc.) +The interval at which Fleet will ask Fleet's agent (fleetd) to update host details (such as uptime, hostname, network interfaces, etc.) Setting this to a higher value can reduce baseline load on the Fleet server in larger deployments. @@ -2553,7 +2553,7 @@ stored in your database. ##### packaging_s3_bucket -This is the name of the S3 bucket to store pre-built Fleetd installers. +This is the name of the S3 bucket to store pre-built Fleet agent (fleetd) installers. - Default value: "" - Environment variable: `FLEET_PACKAGING_S3_BUCKET` diff --git a/docs/Contributing/File-carving.md b/docs/Contributing/File-carving.md index ca14a295ea..57b743f207 100644 --- a/docs/Contributing/File-carving.md +++ b/docs/Contributing/File-carving.md @@ -1,12 +1,12 @@ ## File carving -Fleet supports osquery's file carving functionality as of Fleet 3.3.0. This allows the Fleet server to request files (and sets of files) from osquery agents, returning the full contents to Fleet. +Fleet supports osquery's file carving functionality as of Fleet 3.3.0. This allows the Fleet server to request files (and sets of files) from Fleet's agent (fleetd) returning the full contents to Fleet. File carving data can be either stored in Fleet's database or to an external S3 bucket. For information on how to configure the latter, consult the [configuration docs](https://fleetdm.com/docs/deploying/configuration#s-3-file-carving-backend). ### Configuration -Given a working flagfile for connecting osquery agents to Fleet, add the following flags to enable carving: +Given a working flagfile for connecting fleetd to Fleet, add the following flags to enable carving: ```sh --disable_carver=false @@ -16,7 +16,7 @@ Given a working flagfile for connecting osquery agents to Fleet, add the followi --carver_block_size=8000000 ``` -The default flagfile provided in the "Add New Host" dialog also includes this configuration. +The default flagfile provided in the "Add new host" dialog also includes this configuration. #### Carver block size diff --git a/docs/Deploy/deploy-on-render.md b/docs/Deploy/deploy-on-render.md index 017227238d..50bbe685fb 100644 --- a/docs/Deploy/deploy-on-render.md +++ b/docs/Deploy/deploy-on-render.md @@ -105,7 +105,7 @@ Fleet is up and running, head to your public URL. You should be prompted with a setup page, where you can enter your name, email, and password. Run through those steps and you should have an empty hosts page waiting for you. -You’ll find the enroll-secret after clicking “Add hosts”. This is a special secret the host will need to register to your Fleet instance. Once you have the enroll-secret you can use `fleetctl` to generate installers, which makes installing and updating osquery super simple. +You’ll find the enroll-secret after clicking “Add hosts”. This is a special secret the host will need to register to your Fleet instance. Once you have the enroll-secret you can use `fleetctl` to generate Fleet's agent (fleetd), which makes installing and updating osquery super simple. To install `fleetctl`, which is the command line interface (CLI) used to communicate between your computer and Fleet, you either run `npm install -g fleetctl` or [download fleetctl](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.3.0) from Github. Once it's installed try the following command (Docker require) on your terminal: diff --git a/docs/Deploy/public-ip.md b/docs/Deploy/public-ip.md index 4d108a6ad9..65837f7405 100644 --- a/docs/Deploy/public-ip.md +++ b/docs/Deploy/public-ip.md @@ -1,6 +1,6 @@ # Public IPs of devices -Fleet attempts to deduce the public IP of devices from well-known HTTP headers received on requests made by the osquery agent. +Fleet attempts to deduce the public IP of devices from well-known HTTP headers received on requests made by Fleet's agent (fleetd). The HTTP request headers are checked in the following order: 1. If `True-Client-IP` header is set, then Fleet will extract its value. diff --git a/docs/Get started/FAQ.md b/docs/Get started/FAQ.md index aaacfd3813..8a4d5c2185 100644 --- a/docs/Get started/FAQ.md +++ b/docs/Get started/FAQ.md @@ -12,9 +12,9 @@ Fleet is simple enough to [spin up for yourself](https://fleetdm.com/docs/deploy Fleet provides a standard [Terraform module](https://fleetdm.com/docs/deploy/deploy-on-aws-with-terraform) that deploys Fleet with best practices, along with [cloud cost calculators and reference architectures](https://fleetdm.com/docs/deploy/reference-architectures#cloud-providers) used by some of Fleet’s largest customers with tens and hundreds of thousands of hosts. Fleet Premium customers can also opt for managed hosting provided by Fleet. You can also deploy Fleet anywhere you want. -You can enroll servers and laptops using a simple installer or automatically deliver the agent using your existing tools, such as Chef, Terraform, Munki/autopkg, Ansible, Puppet, Jamf, Intune, etc. +You can enroll servers and laptops using a simple installer or automatically deliver Fleet's agent (fleetd) using your existing tools, such as Chef, Terraform, Munki/autopkg, Ansible, Puppet, Jamf, Intune, etc. -By default, Fleet keeps agents up to date automatically. For self-managed instances, Fleet provides a [migration runner](https://fleetdm.com/docs/deploy/upgrading-fleet#upgrading-fleet). +By default, Fleet keeps fleetd up to date automatically. For self-managed instances, Fleet provides a [migration runner](https://fleetdm.com/docs/deploy/upgrading-fleet#upgrading-fleet). ## What options do I have for access control? What about auditing admin activity? @@ -106,8 +106,8 @@ Anyone is free to contribute to the free or paid features of the project. We are The only way we are able to partner as a business to provide support and build new open source and paid features is through customers purchasing Fleet Premium. -## How can I uninstall the osquery agent? -To uninstall the osquery agent, follow the below instructions for your operating system. +## How can I uninstall fleetd? +To uninstall Fleet's agent (fleetd), follow the below instructions for your operating system. #### MacOS Run the Orbit [cleanup script](https://github.com/fleetdm/fleet/blob/main/orbit/tools/cleanup/cleanup_macos.sh) diff --git a/docs/Get started/anatomy.md b/docs/Get started/anatomy.md index e94e81ff1b..4af59e9086 100644 --- a/docs/Get started/anatomy.md +++ b/docs/Get started/anatomy.md @@ -23,7 +23,7 @@ Fleet Desktop is a menu bar icon that gives end users visibility into the securi The Fleetd Chrome extension enrolls ChromeOS devices in Fleet. [Docs](https://github.com/fleetdm/fleet/blob/main/ee/fleetd-chrome/README.md). ## Host -A host is a computer, server, or other endpoint. Fleet gathers information from an osquery agent installed on each of your hosts. [Docs](https://fleetdm.com/docs/using-fleet/adding-hosts). +A host is a computer, server, or other endpoint. Fleet gathers information from Fleet's agent (fleetd) installed on each of your hosts. [Docs](https://fleetdm.com/docs/using-fleet/adding-hosts). ## Team A team is a group of hosts. Use teams to segment your hosts into groups that reflect your organization's IT and security policies. [Docs](https://fleetdm.com/docs/using-fleet/teams). diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index bf9887808b..8bdaaacbfd 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -628,7 +628,7 @@ for pagination. For a comprehensive list of activity types and detailed informat - [Get carve](#get-carve) - [Get carve block](#get-carve-block) -Fleet supports osquery's file carving functionality as of Fleet 3.3.0. This allows the Fleet server to request files (and sets of files) from osquery agents, returning the full contents to Fleet. +Fleet supports osquery's file carving functionality as of Fleet 3.3.0. This allows the Fleet server to request files (and sets of files) from Fleet's agent (fleetd), returning the full contents to Fleet. To initiate a file carve using the Fleet API, you can use the [live query](#run-live-query) endpoint to run a query against the `carves` table. @@ -3281,7 +3281,7 @@ This report includes a subset of host vitals, and simplified policy and vulnerab Currently supports Windows and MacOS. On MacOS this requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled -in [Fleet's osquery installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +in [Fleet's agent (fleetd)](https://fleetdm.com/docs/get-started/anatomy#fleetd). Retrieves a host's MDM enrollment status and MDM server URL. @@ -3318,7 +3318,7 @@ If the host exists but is not enrolled to an MDM server, then this API returns ` Currently supports Windows and MacOS. On MacOS this requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled -in [Fleet's osquery installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +in [Fleet's agent (fleetd)](https://fleetdm.com/docs/get-started/anatomy#fleetd). Retrieves MDM enrollment summary. Windows servers are excluded from the aggregated data. @@ -3425,8 +3425,7 @@ Retrieves a host's MDM enrollment status, MDM server URL, and Munki version. Requires the [macadmins osquery extension](https://github.com/macadmins/osquery-extension) which comes bundled -in [Fleet's osquery -installers](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +in [Fleet's agent (fleetd)](https://fleetdm.com/docs/get-started/anatomy#fleetd). Currently supported only on macOS. diff --git a/docs/Using Fleet/Learn-how-to-use-Fleet.md b/docs/Using Fleet/Learn-how-to-use-Fleet.md index c9dfb74426..fceea3f7db 100644 --- a/docs/Using Fleet/Learn-how-to-use-Fleet.md +++ b/docs/Using Fleet/Learn-how-to-use-Fleet.md @@ -17,8 +17,8 @@ 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 your Fleet osquery installer. The download may take several seconds. -4. Open the Fleet osquery installer and follow the installation steps. +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. @@ -43,7 +43,7 @@ To run this query on your device: 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 osquery agents to respond with results. Only online hosts will respond with results to a live 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. diff --git a/docs/Using Fleet/Supported-host-operating-systems.md b/docs/Using Fleet/Supported-host-operating-systems.md index 8d6729030a..ce1567877e 100644 --- a/docs/Using Fleet/Supported-host-operating-systems.md +++ b/docs/Using Fleet/Supported-host-operating-systems.md @@ -19,10 +19,10 @@ Not all osquery tables are available for every OS. Please check out the [osquery If a table is not available for your host, Fleet will generally handle things behind the scenes for you. ### M1 Macs -The osquery installer 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`. +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 -The osquery installer will run on Linux distributions where `glibc` is >= 2.2 (there is ongoing work to make osquery work with `glibc` 2.12+). +Fleet's agent (fleetd) will run on Linux distributions where `glibc` is >= 2.2 (there is ongoing work to make osquery work with `glibc` 2.12+). If you aren't sure what version of `glibc` your distribution is using, [DistroWatch](https://distrowatch.com/) is a great resource. > On Linux, Fleet Desktop only supports $DISPLAY `:0`. @@ -32,5 +32,5 @@ If you aren't sure what version of `glibc` your distribution is using, [DistroWa > The `fleetctl package` command is not supported on DISA-STIG distribution. - + diff --git a/docs/Using Fleet/enroll-hosts.md b/docs/Using Fleet/enroll-hosts.md index 08e0827af7..6cfc545339 100644 --- a/docs/Using Fleet/enroll-hosts.md +++ b/docs/Using Fleet/enroll-hosts.md @@ -14,9 +14,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/fleetctl-preview). -The `fleetctl package` command is used to generate a fleetd installer. +The `fleetctl package` command is used to generate Fleet's agent (fleetd). -The `--type` flag is used to specify installer type: +The `--type` flag is used to specify the fleetd installer type: - macOS: .pkg - Windows: .msi - Linux: .deb or .rpm @@ -25,7 +25,7 @@ A `--fleet-url` (Fleet instance URL) and `--enroll-secret` (Fleet enrollment sec #### Example -Generate macOS installer (.pkg) +Generate fleetd on macOS (.pkg) ```json fleetctl package --type pkg --fleet-url=example.fleetinstance.com --enroll-secret=85O6XRG8'!l~P&zWt_'f&$QK(sM8_D4x @@ -35,28 +35,28 @@ Tip: To see all options for `fleetctl package` command, run `fleetctl package -h ## UI -To generate an installer in Fleet UI: +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. -### Generate installer to enroll host to a specific team +### Enroll host to a specific team With hosts segmented into teams, you can apply unique queries and give users access to only the hosts in specific teams. [Learn more about teams](https://fleetdm.com/docs/using-fleet/segment-hosts). -To generate an installer that enrolls to a specific team: from the **Hosts** page, select the desired team from the menu at the top of the screen, then follow the instructions above for generating an installer. The team's enroll secret will be included in the generated command. +To enroll to a specific team: from the **Hosts** page, select the desired team from the menu at the top of the screen, then follow the instructions above for generating Fleet's agent (fleetd). The team's enroll secret will be included in the generated command. ### Enroll multiple hosts If you're managing an enterprise environment with multiple hosts, you likely have an enterprise deployment tool like [Munki](https://www.munki.org/munki/), [Jamf Pro](https://www.jamf.com/products/jamf-pro/), [Chef](https://www.chef.io/), [Ansible](https://www.ansible.com/), or [Puppet](https://puppet.com/) to deliver software to your hosts. -You can use your software management tool of choice to distribute a fleetd installer generated via the instructions above. +You can use your software management tool of choice to distribute Fleet's agent (fleetd) generated via the instructions above. ### 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. -You can include Fleet Desktop in the fleetd installer by including `--fleet-desktop` in the `fleetctl package` command. +You can include Fleet Desktop in Fleet's agent (fleetd) by including `--fleet-desktop` in the `fleetctl package` command. ## Enroll Chromebooks @@ -124,14 +124,14 @@ How to unenroll a host from Fleet: ## Advanced - [Fleet agent (fleetd) components](#fleetd-components) -- [Signing fleetd installer](#signing-fleetd-installer) +- [Signing fleetd](#signing-fleetd) - [Grant full disk access to osquery on macOS](#grant-full-disk-access-to-osquery-on-macos) - [Using mTLS](#using-mtls) - [Specifying update channels](#specifying-update-channels) - [Testing osquery queries locally](#testing-osquery-queries-locally) - [Finding fleetd logs](#finding-fleetd-logs) - [Using system keystore for enroll secret](#using-system-keystore-for-enroll-secret) -- [Generating Windows installers using local WiX toolset](#generating-windows-installers-using-local-wix-toolset) +- [Generating fleetd for Windows using local WiX toolset](#generating-fleetd-for-windows-using-local-wix-toolset) - [Experimental features](#experimental-features) ### fleetd components @@ -153,11 +153,11 @@ graph LR; orbit -- "Auto Update (TLS)" --> tuf; ``` -### Signing fleetd installers +### Signing fleetd - >**Note:** Currently, the `fleetctl package` command does not support signing Windows fleetd installers. Windows installers can be signed after building. + >**Note:** Currently, the `fleetctl package` command does not support signing Windows fleetd. Windows fleetd can be signed after building. -The `fleetctl package` command supports signing and notarizing macOS osquery installers via the +The `fleetctl package` command supports signing and notarizing macOS fleetd via the `--sign-identity` and `--notarize` flags. Check out the example below: @@ -166,7 +166,7 @@ Check out the example below: AC_USERNAME=appleid@example.com AC_PASSWORD=app-specific-password fleetctl package --type pkg --sign-identity=[PATH TO SIGN IDENTITY] --notarize --fleet-url=[YOUR FLEET URL] --enroll-secret=[YOUR ENROLLMENT SECRET] ``` -The above command must be run on a macOS device, as the notarizing and signing of macOS fleetd installers can only be done on macOS devices. +The above command must be run on a macOS device, as the notarizing and signing of macOS fleetd can only be done on macOS devices. Also, remember to replace both `AC_USERNAME` and `AC_PASSWORD` environment variables with your Apple ID and a valid [app-specific](https://support.apple.com/en-ca/HT204397) password, respectively. Some organizations (notably those with Apple Enterprise Developer Accounts) may also need to specify `AC_TEAM_ID`. This value can be found on the [Apple Developer "Membership" page](https://developer.apple.com/account/#!/membership) under "Team ID." @@ -185,7 +185,7 @@ tables that require access to the [EndpointSecurity API](https://developer.apple If you use plain osquery, instructions are [available here](https://osquery.readthedocs.io/en/stable/deployment/process-auditing/). -On a system with osquery installed via the Fleet osquery installer (fleetd), obtain the +On a system with osquery installed via Fleet's agent (fleetd), obtain the `CodeRequirement` of fleetd by running: ```sh @@ -322,11 +322,11 @@ System keystore access can be disabled via `--disable-keystore` flag for the `fl >**Note:** The keychain is not used on macOS when the enroll secret is provided via MDM profile. Keychain support when passing the enroll secret via MDM profile is coming soon. -### Generating Windows installers using local WiX toolset +### Generating fleetd for Windows using local WiX toolset `Applies only to Fleet Premium` -When creating a fleetd installer for Windows hosts (**.msi**) on a Windows or macOS machine, you can tell `fleetctl package` to +When generating Fleet's agent (fleetd) for Windows hosts (**.msi**) on a Windows or macOS machine, you can tell `fleetctl package` to use local installations of the 3 WiX v3 binaries used by this command (`heat.exe`, `candle.exe`, and `light.exe`) instead of those in a pre-configured container, which is the default behavior. To do so: @@ -359,5 +359,5 @@ Applying the environmental variable `"FLEETD_SILENCE_ENROLL_ERROR"=1` on a host 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/docs/Using Fleet/fleetctl-CLI.md b/docs/Using Fleet/fleetctl-CLI.md index d83702e46e..4c12425bf5 100644 --- a/docs/Using Fleet/fleetctl-CLI.md +++ b/docs/Using Fleet/fleetctl-CLI.md @@ -30,7 +30,7 @@ npm install -g fleetctl@latest ### Available commands -Much of the functionality available in the Fleet UI is also available in `fleetctl`. You can run queries, add and remove users, generate agent (fleetd) installers to add new hosts, get information about existing hosts, and more! +Much of the functionality available in the Fleet UI is also available in `fleetctl`. You can run queries, add and remove users, generate Fleet's agent (fleetd) to add new hosts, get information about existing hosts, and more! To see the available commands you can run: @@ -219,5 +219,5 @@ This will generate a `tar.gz` file with: - Files containing database-specific information. - + diff --git a/docs/Using Fleet/update-agents.md b/docs/Using Fleet/update-agents.md index 6349ecb941..93b61c0052 100644 --- a/docs/Using Fleet/update-agents.md +++ b/docs/Using Fleet/update-agents.md @@ -132,13 +132,13 @@ This output is _not sensitive_ and will be shared in agent deployments to verify ### Packaging with fleetd -See the [Enroll hosts docs](https://fleetdm.com/docs/using-fleet/enroll-hosts) for instructions on generating the fleetd agent. - -You can use `fleetctl package` to generate installer packages of fleetd (Fleet's bundle of agents that includes a bootstrapped osquery wrapper) to integrate with your Fleet instance. +You can use `fleetctl package` to generate Fleet's agent (fleetd) to integrate with your Fleet instance. For example running `fleetctl package --type deb --fleet-url= --enroll-secret=` will build a `.deb` installer with everything needed to communicate with your fleet instance. +See the [Enroll hosts docs](https://fleetdm.com/docs/using-fleet/enroll-hosts) for instructions on generating the fleetd agent. + ### Key rotation Key rotation is supported for each of the update role keys via the `fleetctl updates rotate` command. diff --git a/schema/tables/apfs_physical_stores.yml b/schema/tables/apfs_physical_stores.yml index f9c8f1992e..7492e8e7b0 100644 --- a/schema/tables/apfs_physical_stores.yml +++ b/schema/tables/apfs_physical_stores.yml @@ -41,5 +41,5 @@ columns: type: bigint required: false description: The size of the physical store in byptes -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/apfs_volumes.yml b/schema/tables/apfs_volumes.yml index bfb3f1d2a8..3a94e6c535 100644 --- a/schema/tables/apfs_volumes.yml +++ b/schema/tables/apfs_volumes.yml @@ -75,5 +75,5 @@ columns: type: integer required: false description: Whether the volume is unreadable because it does not have a key entered -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/authdb.yml b/schema/tables/authdb.yml index f016ed3084..1785b333d3 100644 --- a/schema/tables/authdb.yml +++ b/schema/tables/authdb.yml @@ -13,5 +13,5 @@ columns: required: false description: >- The JSON output parsed from the plist output of the `authorizationdb read ` command. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/cis_audit.yml b/schema/tables/cis_audit.yml index 79055e0bda..343cdad5f9 100644 --- a/schema/tables/cis_audit.yml +++ b/schema/tables/cis_audit.yml @@ -11,5 +11,5 @@ columns: type: text required: false description: Contains the value for the queried CIS item. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/corestorage_logical_volume_families.yml b/schema/tables/corestorage_logical_volume_families.yml index dafa9e333e..09cc50ecaf 100644 --- a/schema/tables/corestorage_logical_volume_families.yml +++ b/schema/tables/corestorage_logical_volume_families.yml @@ -76,5 +76,5 @@ columns: type: integer required: false description: Whether a password is currently required to unlock the volume -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false \ No newline at end of file diff --git a/schema/tables/corestorage_logical_volumes.yml b/schema/tables/corestorage_logical_volumes.yml index 48558a5cbd..eb3c7d5f86 100644 --- a/schema/tables/corestorage_logical_volumes.yml +++ b/schema/tables/corestorage_logical_volumes.yml @@ -129,7 +129,7 @@ columns: type: text required: false description: Name of the filesystem in the logical volume -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/csrutil_info.yml b/schema/tables/csrutil_info.yml index cbc21425ca..6a903e3209 100644 --- a/schema/tables/csrutil_info.yml +++ b/schema/tables/csrutil_info.yml @@ -11,5 +11,5 @@ columns: During system installation, a SHA-256 cryptographic hash is calculated for all immutable system files and stored in a Merkle tree which itself is hashed as the Seal. Both are stored in the metadata of the snapshot created of the System volume. The seal is verified by the boot loader at startup. macOS will not boot if system files have been tampered with. If validation fails, the user will be instructed to reinstall the operating system. During read operations for files located in the Sealed System Volume, a hash is calculated and compared to the value stored in the Merkle tree. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/dscl.yml b/schema/tables/dscl.yml index a17ae334bd..6698abba00 100644 --- a/schema/tables/dscl.yml +++ b/schema/tables/dscl.yml @@ -19,5 +19,5 @@ columns: type: text required: false description: The value of the read path and key. The value is the empty string if the key doesn't exist. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/file_lines.yml b/schema/tables/file_lines.yml index 71c3fe0760..1f8b2413c4 100644 --- a/schema/tables/file_lines.yml +++ b/schema/tables/file_lines.yml @@ -1,5 +1,5 @@ name: file_lines -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Allows reading an arbitrary file. platforms: - darwin diff --git a/schema/tables/filevault_prk.yml b/schema/tables/filevault_prk.yml index bdab68851f..8797ca1276 100644 --- a/schema/tables/filevault_prk.yml +++ b/schema/tables/filevault_prk.yml @@ -7,5 +7,5 @@ columns: type: text required: false description: The base64-encoded contents of the encrypted FileVault personal recovery key stored at `/var/db/FileVaultPRK.dat` (see also https://developer.apple.com/documentation/devicemanagement/fderecoverykeyescrow) -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/filevault_users.yml b/schema/tables/filevault_users.yml index d085092284..755d0f7532 100644 --- a/schema/tables/filevault_users.yml +++ b/schema/tables/filevault_users.yml @@ -1,5 +1,5 @@ name: filevault_users -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Information on the users able to unlock the current boot volume if protected with FileVault. platforms: - darwin diff --git a/schema/tables/find_cmd.yml b/schema/tables/find_cmd.yml index e3d62df34e..0393688453 100644 --- a/schema/tables/find_cmd.yml +++ b/schema/tables/find_cmd.yml @@ -23,7 +23,5 @@ columns: required: false description: >- Contains the found paths. -notes: >- - This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. - Fleetd installers can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/firmware_eficheck_integrity_check.yml b/schema/tables/firmware_eficheck_integrity_check.yml index 49093b7802..493947378b 100644 --- a/schema/tables/firmware_eficheck_integrity_check.yml +++ b/schema/tables/firmware_eficheck_integrity_check.yml @@ -15,5 +15,5 @@ columns: description: >- Output of the `/usr/libexec/firmwarecheckers/eficheck/eficheck --integrity-check` command. This value is only valid when chip is "intel-t1". -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/google_chrome_profiles.yml b/schema/tables/google_chrome_profiles.yml index 1775ad9eb5..7d21c36e37 100644 --- a/schema/tables/google_chrome_profiles.yml +++ b/schema/tables/google_chrome_profiles.yml @@ -1,5 +1,5 @@ name: google_chrome_profiles -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Profiles configured in Google Chrome. platforms: - darwin diff --git a/schema/tables/icloud_private_relay.yml b/schema/tables/icloud_private_relay.yml index e6f3f86d0b..cd80553e10 100644 --- a/schema/tables/icloud_private_relay.yml +++ b/schema/tables/icloud_private_relay.yml @@ -7,5 +7,5 @@ columns: type: integer required: false description: whether iCloud Private Relay is on or off. 1 is on. 0 is off. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/macadmins_unified_log.yml b/schema/tables/macadmins_unified_log.yml index bd8c6a85af..b77a52c0f1 100644 --- a/schema/tables/macadmins_unified_log.yml +++ b/schema/tables/macadmins_unified_log.yml @@ -1,5 +1,5 @@ name: macadmins_unified_log -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Allows querying macOS [unified logs](https://developer.apple.com/documentation/os/logging). platforms: - darwin diff --git a/schema/tables/macos_profiles.yml b/schema/tables/macos_profiles.yml index 782b63f387..18836ed229 100644 --- a/schema/tables/macos_profiles.yml +++ b/schema/tables/macos_profiles.yml @@ -1,5 +1,5 @@ name: macos_profiles -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: High level information on installed profiles enrollment. platforms: - darwin diff --git a/schema/tables/macos_rsr.yml b/schema/tables/macos_rsr.yml index 3bbe177f62..3338b6749d 100644 --- a/schema/tables/macos_rsr.yml +++ b/schema/tables/macos_rsr.yml @@ -1,5 +1,5 @@ name: macos_rsr -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Returns information about installed Rapid Security Responses (RSRs). platforms: - darwin diff --git a/schema/tables/mdm.yml b/schema/tables/mdm.yml index abf9c15391..a63e09b13c 100644 --- a/schema/tables/mdm.yml +++ b/schema/tables/mdm.yml @@ -1,5 +1,10 @@ name: mdm -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer).

Code based on work by [Kolide](https://github.com/kolide/launcher).

Due to changes in macOS 12.3, the output of `profiles show -type enrollment` can only be generated once a day. If you are running this command with another tool, you should set the `PROFILES_SHOW_ENROLLMENT_CACHE_PATH` environment variable to the path you are caching this. The cache file should be `json` with the keys `dep_capable` and `rate_limited present`, both booleans representing whether the device is capable of DEP enrollment and whether the response from `profiles show -type enrollment` is being rate limited or not. +notes: >- + - This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + + - Code based on work by [Kolide](https://github.com/kolide/launcher). + + - Due to changes in macOS 12.3, the output of `profiles show -type enrollment` can only be generated once a day. If you are running this command with another tool, you should set the `PROFILES_SHOW_ENROLLMENT_CACHE_PATH` environment variable to the path you are caching this. The cache file should be `json` with the keys `dep_capable` and `rate_limited present`, both booleans representing whether the device is capable of DEP enrollment and whether the response from `profiles show -type enrollment` is being rate limited or not. description: Information on the device's MDM enrollment. platforms: - darwin diff --git a/schema/tables/mdm_bridge.yml b/schema/tables/mdm_bridge.yml index 3656d7fba9..a247ae37e9 100644 --- a/schema/tables/mdm_bridge.yml +++ b/schema/tables/mdm_bridge.yml @@ -23,5 +23,5 @@ columns: type: text required: false description: The full raw output of the MDM command execution. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/munki_info.yml b/schema/tables/munki_info.yml index 7c95b8d28e..b837daa4d7 100644 --- a/schema/tables/munki_info.yml +++ b/schema/tables/munki_info.yml @@ -1,5 +1,8 @@ name: munki_info -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer).

Code based on work by [macadmins/osquery-extension](https://github.com/macadmins/osquery-extension) and [Kolide](https://github.com/kolide/launcher). +notes: >- + - This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + + - Code based on work by [macadmins/osquery-extension](https://github.com/macadmins/osquery-extension) and [Kolide](https://github.com/kolide/launcher). description: Information from the last [Munki](https://github.com/munki/munki) run. platforms: - darwin diff --git a/schema/tables/munki_installs.yml b/schema/tables/munki_installs.yml index 3f05dfc3fd..dfa49b6844 100644 --- a/schema/tables/munki_installs.yml +++ b/schema/tables/munki_installs.yml @@ -1,5 +1,8 @@ name: munki_installs -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer).

Code based on work by [macadmins/osquery-extension](https://github.com/macadmins/osquery-extension) and [Kolide](https://github.com/kolide/launcher). +notes: >- + - This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). + + - Code based on work by [macadmins/osquery-extension](https://github.com/macadmins/osquery-extension) and [Kolide](https://github.com/kolide/launcher). description: Software packages and other items [Munki](https://github.com/munki/munki) is managing. platforms: - darwin diff --git a/schema/tables/nvram_info.yml b/schema/tables/nvram_info.yml index 7a37e987c7..321788bee4 100644 --- a/schema/tables/nvram_info.yml +++ b/schema/tables/nvram_info.yml @@ -9,5 +9,5 @@ columns: description: >- Apple Mobile File Integrity (AMFI) was first released in macOS 10.12. The daemon and service block attempts to run unsigned code. AMFI uses lanchd, code signatures, certificates, entitlements, and provisioning profiles to create a filtered entitlement dictionary for an app. AMFI is the macOS kernel module that enforces code-signing and library validation. Note: AMFI cannot be disabled with SIP enabled, but a change attempt can be made that will appear successful, and report incorrectly as successful. If the AMFI audit fails, and the SIP audit passes, this is still an issue the admin should research. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/orbit_info.yml b/schema/tables/orbit_info.yml index 1dfa3a9ff0..cc0d923682 100644 --- a/schema/tables/orbit_info.yml +++ b/schema/tables/orbit_info.yml @@ -45,5 +45,5 @@ columns: type: integer required: false description: 1 if running scripts is enabled, 0 if disabled. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/parse_ini.yml b/schema/tables/parse_ini.yml index 3d33596f39..e5f2719af7 100644 --- a/schema/tables/parse_ini.yml +++ b/schema/tables/parse_ini.yml @@ -1,5 +1,5 @@ name: parse_ini -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Parse a file as INI configuration. platforms: - darwin diff --git a/schema/tables/parse_json.yml b/schema/tables/parse_json.yml index 7a0e9a339a..fd1e62c526 100644 --- a/schema/tables/parse_json.yml +++ b/schema/tables/parse_json.yml @@ -1,5 +1,5 @@ name: parse_json -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Parses an entire file as JSON. See `parse_jsonl` where multiple JSON documents are supported. platforms: - darwin diff --git a/schema/tables/parse_jsonl.yml b/schema/tables/parse_jsonl.yml index 7aae0eef65..c2664437f7 100644 --- a/schema/tables/parse_jsonl.yml +++ b/schema/tables/parse_jsonl.yml @@ -1,5 +1,5 @@ name: parse_jsonl -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Parses each line of a file as a separate JSON document. See `parse_json` to treat an entire file as a single JSON document. platforms: - darwin diff --git a/schema/tables/parse_xml.yml b/schema/tables/parse_xml.yml index 21b3fe2a40..6f8ea31658 100644 --- a/schema/tables/parse_xml.yml +++ b/schema/tables/parse_xml.yml @@ -1,5 +1,5 @@ name: parse_xml -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Parses a file as an XML document. platforms: - darwin diff --git a/schema/tables/pmset.yml b/schema/tables/pmset.yml index 2e975c495f..e65779f26c 100644 --- a/schema/tables/pmset.yml +++ b/schema/tables/pmset.yml @@ -11,7 +11,5 @@ columns: type: text required: false description: Result of the command in JSON format. -notes: >- - This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. - Fleetd installers can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/puppet_info.yml b/schema/tables/puppet_info.yml index 4e704fc4f4..81a22390b8 100644 --- a/schema/tables/puppet_info.yml +++ b/schema/tables/puppet_info.yml @@ -1,5 +1,5 @@ name: puppet_info -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Information on the last [Puppet](https://puppet.com/) run. This table uses data from the `last_run_report` that Puppet creates. platforms: - darwin diff --git a/schema/tables/puppet_logs.yml b/schema/tables/puppet_logs.yml index 9b84f426e7..2600f21539 100644 --- a/schema/tables/puppet_logs.yml +++ b/schema/tables/puppet_logs.yml @@ -1,5 +1,5 @@ name: puppet_logs -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: Outputs [Puppet](https://puppet.com/) logs from the last run. platforms: - darwin diff --git a/schema/tables/puppet_state.yml b/schema/tables/puppet_state.yml index ad8db96d8d..55bf80ff34 100644 --- a/schema/tables/puppet_state.yml +++ b/schema/tables/puppet_state.yml @@ -1,5 +1,5 @@ name: puppet_state -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). description: State of every resource [Puppet](https://puppet.com/) is managing. This table uses data from the `last_run_report` that Puppet creates. platforms: - darwin diff --git a/schema/tables/pwd_policy.yml b/schema/tables/pwd_policy.yml index 6f0bf193ca..9f9dac97fc 100644 --- a/schema/tables/pwd_policy.yml +++ b/schema/tables/pwd_policy.yml @@ -28,7 +28,5 @@ columns: required: false description: >- This parameter indicates the minimum number of mixed characters in a password. -notes: >- - This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. - Fleetd installers can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/sntp_request.yml b/schema/tables/sntp_request.yml index 8e8d68b09e..3a1b9acbd1 100644 --- a/schema/tables/sntp_request.yml +++ b/schema/tables/sntp_request.yml @@ -17,7 +17,5 @@ columns: type: bigint required: false description: Offset between the host's time and the SNTP time in milliseconds. -notes: >- - This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. - Fleetd installers can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/software_update.yml b/schema/tables/software_update.yml index 975d46a23c..9de2839c60 100644 --- a/schema/tables/software_update.yml +++ b/schema/tables/software_update.yml @@ -8,5 +8,5 @@ columns: required: false description: >- If true, means one of the Apple softwares installed on this machine has a new available upgrade. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/sudo_info.yml b/schema/tables/sudo_info.yml index 074c2f5850..8ae828a515 100644 --- a/schema/tables/sudo_info.yml +++ b/schema/tables/sudo_info.yml @@ -7,5 +7,5 @@ columns: type: text required: false description: A JSON document with the key value pairs parsed from `sudo -V` output. -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false diff --git a/schema/tables/user_login_settings.yml b/schema/tables/user_login_settings.yml index cf3e0b1a03..02aaa35ec8 100644 --- a/schema/tables/user_login_settings.yml +++ b/schema/tables/user_login_settings.yml @@ -7,5 +7,5 @@ columns: type: integer required: false description: whether password hint is enabled for any user. 1 means one or more users has a password hint set, 0 means no user has a password hint set -notes: This table is not a core osquery table. It is included as part of [Fleetd](https://fleetdm.com/docs/using-fleet/orbit), the osquery manager from Fleet. Fleetd can be built with [fleetctl](https://fleetdm.com/docs/using-fleet/adding-hosts#osquery-installer). +notes: This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)). evented: false From b903e5258f43c1bda032b61b6b5c25ac1dc7d380 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Mon, 29 Apr 2024 11:22:54 -0500 Subject: [PATCH 65/83] Handbook: Make departmental page preambles consistent (#18594) --- handbook/business-operations/README.md | 5 ++- handbook/company/communications.md | 43 ++++++++++++++++++++------ handbook/customer-success/README.md | 5 ++- handbook/demand/README.md | 13 ++++---- handbook/digital-experience/README.md | 9 +++--- handbook/engineering/README.md | 12 ++++--- handbook/product-design/README.md | 11 +++---- handbook/sales/README.md | 11 +++---- 8 files changed, 64 insertions(+), 45 deletions(-) diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 3ff5f6d363..93fb1e69d6 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -1,12 +1,11 @@ # Business Operations -This handbook page details processes specific to working [with](#what-we-do) and [within](#responsibilities) the Business Operations (BizOps) department. +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))_ -| Community Advocate | [JD Strong](https://www.linkedin.com/in/jackdaniyelstrong/) _([@spokanemac](https://github.com/spokanemac/spokanemac))_ -| 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))_ +| 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). diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 5df2104ffa..bb46f45a2a 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -13,19 +13,41 @@ You can read about the company's positioning and product strategy in ["🎐 Why We track competitors' capabilities and adjacent (or commonly integrated) products in Google doc [Competition](https://docs.google.com/document/d/1Bqdui6oQthdv5XtD5l7EZVB-duNRcqVRg7NVA4lCXeI/edit) (private Google doc). ## Directly responsible individuals (DRIs) -| Responsibility | DRI | -| -------------- | --- | -| Intentionality of Fleet's interfaces | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/noahtalerman))_ | -| Best practices for using Fleet | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/noahtalerman))_ | -| What goes in a release | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ | -| Engineering output and architecture | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ | -| Structure and intentionality of the [Docs](https://fleetdm.com/docs/get-started/why-fleet)| [Mike Thomas](https://www.linkedin.com/in/mike-thomas-52277938) _([@mike-j-thomas](https://github.com/mike-j-thomas))_ | -| Design and content of the [Docs](https://fleetdm.com/docs/get-started/why-fleet) | [Rachael Shaw](https://www.linkedin.com/in/rachaelcshaw/) _([@rachaelshaw](https://github.com/rachaelshaw))_ | -| API design | [Rachael Shaw](https://www.linkedin.com/in/rachaelcshaw/) _([@rachaelshaw](https://github.com/rachaelshaw))_ | +| Area of responsibility | [DRI](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) | +| -------------- | --- | +| Revenue | _See [🐋 Chief Revenue Officer](https://fleetdm.com/handbook/sales#team)_ +| Pricing | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ +| Illustrations | _See [🌐 Head of Design](https://fleetdm.com/handbook/digital-experience#team)_ +| Website | _See [🌐 Digital Experience team](https://fleetdm.com/handbook/digital-experience#team)_ +| Product marketing (PMM) | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ +| Brand marketing | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ +| Public relations | _See [🛠️ CEO responsibilities](https://fleetdm.com/handbook/company/leadership#ceo-responsibilities)_ +| Revenue pipeline | _See [🫧 Head of Demand Generation](https://fleetdm.com/handbook/demand#team)_ +| Ads | _See [🫧 Demand team](https://fleetdm.com/handbook/demand#team)_ +| Video | _See [🫧 Digital Marketing Manager](https://fleetdm.com/handbook/demand#team)_ +| 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)_ +| 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)_ +| Quality assurance (QA) | _See [🚀 Engineering team](https://fleetdm.com/handbook/engineering#team)_ +| Features & product adoption | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ +| Feature prioritization | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ +| Intentionality of Fleet's interfaces | _See [🦢 Head of Product Design](https://fleetdm.com/handbook/product-design#team)_ +| Best practices for using Fleet | _See [🦢 Product Design team](https://fleetdm.com/handbook/product-design#team)_ +| [API design](https://fleetdm.com/docs/rest-api/rest-api) | _See [🦢 Rachael Shaw](https://fleetdm.com/handbook/product-design#team)_ +| Structure of the [docs](https://fleetdm.com/docs/get-started/why-fleet) | _See [🌐 Head of Design](https://fleetdm.com/handbook/digital-experience#team)_ +| Product reference documentation | _See [🦢 Rachael Shaw](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)_ ### Docs -This page details processes related to maintaining and updating the ([Fleet docs](https://fleetdm.com/docs)). +This page details processes related to maintaining and updating the [Fleet documentation](https://fleetdm.com/docs). When someone asks a question in a public channel, it's safe to assume they aren't the only person looking for an answer. @@ -33,6 +55,7 @@ To make our docs as helpful as possible, the Community team gathers these questi Fleet's goal is to answer every question with a link to the docs and/or result in a documentation update. +> Fleet's philosophy on how to write useful documentation is public and open-source: ["Why read documentation?"](https://fleetdm.com/handbook/company/why-this-way#why-read-documentation) ## Fleetdm.com Any change to fleetdm.com follows the same process as [making changes](https://fleetdm.com/handbook/company/product-groups#making-changes) to the core product. To propose a change to Fleet's website [create a website request](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=website-request.md&title=Request%3A+__________________________) on the #g-digital-experience kanban board. diff --git a/handbook/customer-success/README.md b/handbook/customer-success/README.md index 2f96814de7..9ffcf30914 100644 --- a/handbook/customer-success/README.md +++ b/handbook/customer-success/README.md @@ -5,10 +5,9 @@ This handbook page details processes specific to working [with](#contact-us) and | Role | Contributor(s) | |:--------------------------------------|:------------------------------------------------------------------------------------------------------------------------| | VP of Customer Success | [Zay Hanlon](https://www.linkedin.com/in/zayhanlon/) _([@zayhanlon](https://github.com/zayhanlon))_ -| Customer Success Managers (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))_ -| Customer Solutions Architect (CSA) | [Brock Walters](https://www.linkedin.com/in/brock-walters-247a2990/) _([@nonpunctual](https://github.com/nonpunctual))_ -| Customer Support Engineer (CSE) | [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))_, Ben Edwards _([@edwardsb](https://github.com/edwardsb))_ | 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))_
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))_ ## Contact us - To **make a request** of this department, [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-customer-success&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 the [#g-customer-success](https://fleetdm.slack.com/archives/C062D0THVV1)). diff --git a/handbook/demand/README.md b/handbook/demand/README.md index 2360016984..85717e41fe 100644 --- a/handbook/demand/README.md +++ b/handbook/demand/README.md @@ -2,11 +2,10 @@ This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. ## Team -| Role | Contributor(s) -|:--------------------------------|:------------------------------------------------------------------------------------------------------------------------| -| 🫧 Head of Demand Generation | [Dustin Gerdes](https://www.linkedin.com/in/dustingerdes/) _([@3kindsoffish](https://github.com/3kindsoffish))_ -| 🫧 Field Marketer | [Drew Baker](https://www.linkedin.com/in/andrew-baker-51547179/) _([@drewbakerfdm](https://github.com/drewbakerfdm))_ -| _🎐 Head of Brand & Product Marketing (CEO)_ | [Mike McNeil](https://www.linkedin.com/in/mikermcneil) _([@mikermcneil](https://github.com/mikermcneil))_ +| Role | Contributor(s) +|:-----------------------------------|:------------------------------------------------------------------------------------------------------------------------| +| Head of Demand Generation | [Dustin Gerdes](https://www.linkedin.com/in/dustingerdes/) _([@3kindsoffish](https://github.com/3kindsoffish))_ +| Digital Marketing Manager | [Drew Baker](https://www.linkedin.com/in/andrew-baker-51547179/) _([@drewbakerfdm](https://github.com/drewbakerfdm))_ ## Contact us @@ -14,10 +13,10 @@ This handbook page details processes specific to working [with](#contact-us) and - 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-demand-64e6c8e2d35c7f001a457b7f/board?sprints=none) for this department, including pending tasks and the status of new requests. -> To **make a request** related to **product marketing**, **press**, **brandfronts**, **pitchfronts**, **featurefronts**, **ideal customer profiles (ICPs)**, **personas**, or **targeting** [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Product%20marketing%20request%3A+_______________________) (If urgent, at-mention the [Head of Product Marketing](#team) in the [help-pmm-2023](https://fleetdm.slack.com/archives/C0600L1TTPY) Slack channel). +> To **make a request** related to **product marketing**, **press**, **brandfronts**, **pitchfronts**, **featurefronts**, **ideal customer profiles (ICPs)**, **personas**, or **targeting** [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Product%20marketing%20request%3A+_______________________) (If urgent, at-mention the [Head of Product Marketing](#team) in the [#help-leadership](https://fleetdm.slack.com/archives/C0600L1TTPY) Slack channel). ## Responsibilities -The Demand department is directly responsible for growing awareness of Fleet and nurturing the community through participation in events, conversations, and other [programs](https://fleetdm.com/handbook/company/communications#programs). +The Demand department is directly responsible for achieving revenue pipeline targets, increasing awareness and interest in the open-source project, and nurturing the Fleet community through participation in video, sponsored events, and other [programs](https://fleetdm.com/handbook/company/communications#programs). ### Respond to a "Contact us" submission 1. Check the [_from-prospective-customers](https://fleetdm.slack.com/archives/C01HE9GQW6B) Slack channel for "Contact us" submissions. diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index 5f973ae726..f76e62ad2c 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -4,9 +4,10 @@ This page details processes specific to working [with](#contact-us) and [within] ## Team | Role | Contributor(s) |:--------------------------------|:----------------------------------------------------------------------| -| Head of Digital Experience | [Sam Pfluger](https://www.linkedin.com/in/sampfluger88/) _([@sampfluger88](https://github.com/sampfluger88))_ -| 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))_ +| Head of Digital Experience | See [🌐 Apprentice to the CEO](https://fleetdm.com/handbook/digital-experience#team) +| 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 | [Sam Pfluger](https://www.linkedin.com/in/sampfluger88/) _([@sampfluger88](https://github.com/sampfluger88))_
[Savannah Friend](https://www.linkedin.com/in/savannah-friend-2b1a53148/) _(@todo)_ ## Contact us @@ -15,11 +16,11 @@ This page details processes specific to working [with](#contact-us) and [within] - Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-sales-64fbb46c65f9ff003a1530a8/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. -> _**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`._ ## 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. +> _**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 Each PR to the website is manually checked for quality and tested before before going live on fleetdm.com. To test any change to fleetdm.com diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index c12efec050..f8262fae36 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -1,13 +1,15 @@ # Engineering -This handbook page details processes specific to working [with](#team) and [within](#responsibilities) this department +This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. ## Team | Role                            | Contributor(s) | |:--------------------------------|:-----------------------------------------------------------------------------------------------------------| -| CTO | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ -| Engineering Manager | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ -| Quality Assurance | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ -| Developer | _See ["Current product groups"](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ +| Chief Technology Officer (CTO) | [Luke Heath](https://www.linkedin.com/in/lukeheath/) _([@lukeheath](https://github.com/lukeheath))_ +| Client Platform Engineer & Community Advocate | [JD Strong](https://www.linkedin.com/in/jackdaniyelstrong/) _([@spokanemac](https://github.com/spokanemac/spokanemac))_ +| Engineering Manager (EM) | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ +| Quality Assurance Engineer (QA) | _See [🛩️ Product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups)_ +| Software Engineer | _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://fleetdm.com/handbook/company/product-groups#current-product-groups) and a team member will get back to you within one business day (If urgent, mention a [team member](#team) in the [#help-engineering](https://fleetdm.slack.com/archives/C019WG4GH0A) Slack channel. diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index a560a0764e..a224150441 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -1,14 +1,11 @@ -# Product design +# Product Design This handbook page details processes specific to working [with](#contact-us) and [within](#responsibilities) this department. ## Team -| Role | Contributor(s) | -|:--------------------------------|:-----------------------------------------------------------------------------------------------------------| +| Role | Contributor(s) | +|:---------------------------------|:-----------------------------------------------------------------------------------------------------------| | Head of Product Design | [Noah Talerman](https://www.linkedin.com/in/noah-talerman/) _([@noahtalerman](https://github.com/noahtalerman))_ -| Head of Design | [Mike Thomas](https://www.linkedin.com/in/mike-thomas-52277938) _([@mike-j-thomas](https://github.com/mike-j-thomas))_ -| Product Designer | [Rachael Shaw](https://www.linkedin.com/in/rachaelcshaw/) _([@rachaelshaw](https://github.com/rachaelshaw))_, [Marko Lisica](https://www.linkedin.com/in/markolisica/) _([@marko-lisica](https://github.com/marko-lisica))_ -| Developer | [Eric Shaw](https://www.linkedin.com/in/eric-shaw-1423831a9/) _([@eashaw](https://github.com/eashaw))_ - +| 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`. diff --git a/handbook/sales/README.md b/handbook/sales/README.md index b1fe017ad4..6201676cc5 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -4,12 +4,11 @@ This handbook page details processes specific to working [with](#contact-us) and ## Team | Role                                  | Contributor(s) | |:--------------------------------------|:------------------------------------------------------------------------------------------------------------------------| -| Chief Revenue Officer (CRO) | [Alex Mitchell](https://www.linkedin.com/in/alexandercmitchell/) _([@alexmitchelliii](https://github.com/alexmitchelliii))_ -| 🏹 [Customer Success](https://www.fleetdm.com/handbook/customer-success#responsibilities) | [Customer Success team members](https://www.fleetdm.com/handbook/customer-success#team) -| Director of Solutions Consulting | [Dave Herder](https://www.linkedin.com/in/daveherder/) _([@dherder](https://github.com/dherder))_ -| Solutions Consultant (SC) | [Will Mayhone](https://www.linkedin.com/in/william-mayhone-671977b6/) _([@willmayhone88](https://github.com/willmayhone88))_ -| Head of Public Sector | [Keith Barnes](https://www.linkedin.com/in/keith-barnes-8b666/) _([@KAB703](https://github.com/KAB703))_ -| Account Executive (AE) | [Tom Ostertag](https://www.linkedin.com/in/tom-ostertag-77212791/) _([@TomOstertag](https://github.com/TomOstertag))_, [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))_ +| 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))_
[Will Mayhone](https://www.linkedin.com/in/william-mayhone-671977b6/) _([@willmayhone88](https://github.com/willmayhone88))_ +| Public Sector | [Keith Barnes](https://www.linkedin.com/in/keith-barnes-8b666/) _([@KAB703](https://github.com/KAB703))_ +| 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))_ ## Contact us From bf0f6ec55a8d5b81d13f816adbd17f430eb68aef Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 29 Apr 2024 11:51:40 -0500 Subject: [PATCH 66/83] Added release-fleetd-base workflow. (#18194) #16347 New GitHub workflow. - Uses `tools/tuf/status/tuf-status.go` to check the latest osquery/orbit/fleet-desktop versions - Uploads https://download-testing.fleetdm.com/meta.json to keep track of versions - macOS: https://download-testing.fleetdm.com/fleetd-base.pkg - Windows: https://download-testing.fleetdm.com/fleetd-base.msi This version creates and uploads macOS and fleetd base packages to https://download-testing.fleetdm.com QA instructions updated in the issue. After QA, we will update the workflow to upload to https://download.fleetdm.com --- .github/actions/r2-upload/action.yml | 34 ++++ .github/scripts/rclone-install.sh | 212 ++++++++++++++++++++++ .github/workflows/release-fleetd-base.yml | 177 ++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 .github/actions/r2-upload/action.yml create mode 100755 .github/scripts/rclone-install.sh create mode 100644 .github/workflows/release-fleetd-base.yml diff --git a/.github/actions/r2-upload/action.yml b/.github/actions/r2-upload/action.yml new file mode 100644 index 0000000000..60fb7e0786 --- /dev/null +++ b/.github/actions/r2-upload/action.yml @@ -0,0 +1,34 @@ +name: R2 upload +description: Upload a file to R2 +# Schema: https://json.schemastore.org/github-action.json + +# This action expects the following env vars to be set: +# - R2_ENDPOINT: The endpoint of the R2 instance to upload to +# - R2_ACCESS_KEY_ID: The access key ID to use for R2 +# - R2_ACCESS_KEY_SECRET: The access key secret to use for R2 +# - R2_BUCKET: The bucket to upload to + +inputs: + filename: + # Future improvement: accept array of filenames as JSON string, and loop over it like in https://www.starkandwayne.com/blog/bash-for-loop-over-json-array-using-jq/index.html + description: 'Name of the file to upload' + required: true + +runs: + using: 'composite' + steps: + - name: Upload file to R2 + shell: bash + run: | + sudo ./.github/scripts/rclone-install.sh + mkdir -p ~/.config/rclone + echo "[r2] + type = s3 + provider = Cloudflare + region = auto + no_check_bucket = true + access_key_id = $R2_ACCESS_KEY_ID + secret_access_key = $R2_ACCESS_KEY_SECRET + endpoint = $R2_ENDPOINT + " > ~/.config/rclone/rclone.conf + rclone copy --verbose ${{ inputs.filename }} r2:${R2_BUCKET}/ diff --git a/.github/scripts/rclone-install.sh b/.github/scripts/rclone-install.sh new file mode 100755 index 0000000000..da6a276253 --- /dev/null +++ b/.github/scripts/rclone-install.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash + +# This script is a modified version of MIT licensed script from https://github.com/rclone/rclone/blob/v1.66.0/docs/content/install.sh +# The script is used to install rclone on a GitHub Actions runner machine. +# We use a specific version of rclone for stability/security reasons. +download_version="v1.66.0" + +# error codes +# 0 - exited without problems +# 1 - parameters not supported were used or some unexpected error occurred +# 2 - OS not supported by this script +# 0 (was 3) - installed version of rclone is up to date +# 4 - supported unzip tools are not available + +set -e + +#when adding a tool to the list make sure to also add its corresponding command further in the script +unzip_tools_list=('unzip' '7z' 'busybox') + +usage() { echo "Usage: sudo -v ; curl https://rclone.org/install.sh | sudo bash [-s beta]" 1>&2; exit 1; } + +#check for beta flag +if [ -n "$1" ] && [ "$1" != "beta" ]; then + usage +fi + +if [ -n "$1" ]; then + install_beta="beta " +fi + + +#create tmp directory and move to it with macOS compatibility fallback +tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'rclone-install.XXXXXXXXXX') +cd "$tmp_dir" + + +#make sure unzip tool is available and choose one to work with +set +e +for tool in ${unzip_tools_list[*]}; do + trash=$(hash "$tool" 2>>errors) + if [ "$?" -eq 0 ]; then + unzip_tool="$tool" + break + fi +done +set -e + +# exit if no unzip tools available +if [ -z "$unzip_tool" ]; then + printf "\nNone of the supported tools for extracting zip archives (${unzip_tools_list[*]}) were found. " + printf "Please install one of them and try again.\n\n" + exit 4 +fi + +# Make sure we don't create a root owned .config/rclone directory #2127 +export XDG_CONFIG_HOME=config + +#check installed version of rclone to determine if update is necessary +version=$(rclone --version 2>>errors | head -n 1) +if [ -z "$install_beta" ]; then + current_version=$(curl -fsS https://downloads.rclone.org/version.txt) +else + current_version=download_version + # current_version=$(curl -fsS https://beta.rclone.org/version.txt) +fi + +if [ "$version" = "$current_version" ]; then + printf "\nThe latest ${install_beta}version of rclone ${version} is already installed.\n\n" + exit 0 # originally 3 +fi + + +#detect the platform +OS="$(uname)" +case $OS in + Linux) + OS='linux' + ;; + FreeBSD) + OS='freebsd' + ;; + NetBSD) + OS='netbsd' + ;; + OpenBSD) + OS='openbsd' + ;; + Darwin) + OS='osx' + binTgtDir=/usr/local/bin + man1TgtDir=/usr/local/share/man/man1 + ;; + SunOS) + OS='solaris' + echo 'OS not supported' + exit 2 + ;; + *) + echo 'OS not supported' + exit 2 + ;; +esac + +OS_type="$(uname -m)" +case "$OS_type" in + x86_64|amd64) + OS_type='amd64' + ;; + i?86|x86) + OS_type='386' + ;; + aarch64|arm64) + OS_type='arm64' + ;; + armv7*) + OS_type='arm-v7' + ;; + armv6*) + OS_type='arm-v6' + ;; + arm*) + OS_type='arm' + ;; + *) + echo 'OS type not supported' + exit 2 + ;; +esac + + +#download and unzip +if [ -z "$install_beta" ]; then + download_link="https://downloads.rclone.org/${download_version}/rclone-${download_version}-${OS}-${OS_type}.zip" + rclone_zip="rclone-${download_version}-${OS}-${OS_type}.zip" + # download_link="https://downloads.rclone.org/rclone-current-${OS}-${OS_type}.zip" + # rclone_zip="rclone-current-${OS}-${OS_type}.zip" +else + download_link="https://beta.rclone.org/rclone-beta-latest-${OS}-${OS_type}.zip" + rclone_zip="rclone-beta-latest-${OS}-${OS_type}.zip" +fi + +curl -OfsS "$download_link" +unzip_dir="tmp_unzip_dir_for_rclone" +# there should be an entry in this switch for each element of unzip_tools_list +case "$unzip_tool" in + 'unzip') + unzip -a "$rclone_zip" -d "$unzip_dir" + ;; + '7z') + 7z x "$rclone_zip" "-o$unzip_dir" + ;; + 'busybox') + mkdir -p "$unzip_dir" + busybox unzip "$rclone_zip" -d "$unzip_dir" + ;; +esac + +cd $unzip_dir/* + +#mounting rclone to environment + +case "$OS" in + 'linux') + #binary + cp rclone /usr/bin/rclone.new + chmod 755 /usr/bin/rclone.new + chown root:root /usr/bin/rclone.new + mv /usr/bin/rclone.new /usr/bin/rclone + #manual +# if ! [ -x "$(command -v mandb)" ]; then +# echo 'mandb not found. The rclone man docs will not be installed.' +# else +# mkdir -p /usr/local/share/man/man1 +# cp rclone.1 /usr/local/share/man/man1/ +# mandb +# fi + ;; + 'freebsd'|'openbsd'|'netbsd') + #binary + cp rclone /usr/bin/rclone.new + chown root:wheel /usr/bin/rclone.new + mv /usr/bin/rclone.new /usr/bin/rclone + #manual + mkdir -p /usr/local/man/man1 + cp rclone.1 /usr/local/man/man1/ + makewhatis + ;; + 'osx') + #binary + mkdir -m 0555 -p ${binTgtDir} + cp rclone ${binTgtDir}/rclone.new + mv ${binTgtDir}/rclone.new ${binTgtDir}/rclone + chmod a=x ${binTgtDir}/rclone + #manual +# mkdir -m 0555 -p ${man1TgtDir} +# cp rclone.1 ${man1TgtDir} +# chmod a=r ${man1TgtDir}/rclone.1 + ;; + *) + echo 'OS not supported' + exit 2 +esac + +#update version variable post install +version=$(rclone --version 2>>errors | head -n 1) + +#cleanup +rm -rf "$tmp_dir" + +printf "\n${version} has successfully installed." +printf '\nNow run "rclone config" for setup. Check https://rclone.org/docs/ for more details.\n\n' +exit 0 diff --git a/.github/workflows/release-fleetd-base.yml b/.github/workflows/release-fleetd-base.yml new file mode 100644 index 0000000000..6d3a0e2e61 --- /dev/null +++ b/.github/workflows/release-fleetd-base.yml @@ -0,0 +1,177 @@ +name: Upload fleetd base to https://download.fleetdm.com + +on: + workflow_dispatch: # Manual + schedule: + - cron: '0 3 * * *' # Nightly 3AM UTC + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}} + cancel-in-progress: true + +defaults: + run: + # fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +permissions: + contents: read + +env: + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_DOWNLOAD_TESTING_ACCESS_KEY_ID }} # Production: ${{ secrets.R2_DOWNLOAD_ACCESS_KEY_ID }} | Testing: ${{ secrets.R2_DOWNLOAD_TESTING_ACCESS_KEY_ID }} + R2_ACCESS_KEY_SECRET: ${{ secrets.R2_DOWNLOAD_TESTING_ACCESS_KEY_SECRET }} # Production: ${{ secrets.R2_DOWNLOAD_ACCESS_KEY_SECRET }} | Testing: ${{ secrets.R2_DOWNLOAD_TESTING_ACCESS_KEY_SECRET }} + R2_BUCKET: download-testing # Production: download | Testing: download-testing + BASE_URL: https://download-testing.fleetdm.com # Production: https://download.fleetdm.com | Testing: https://download-testing.fleetdm.com + +jobs: + check-for-fleetd-component-updates: + runs-on: ubuntu-latest + outputs: + update_needed: ${{ steps.check-for-fleetd-component-updates.outputs.update_needed }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + 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: Check for fleetd component updates + id: check-for-fleetd-component-updates + run: | + go run tools/tuf/status/tuf-status.go channel-version -channel stable --components orbit,desktop,osqueryd --format json > latest-meta.json + curl -O $BASE_URL/meta.json + if diff latest-meta.json meta.json >/dev/null 2>&1 + then + echo "update_needed=false" >> $GITHUB_OUTPUT + else + echo "update_needed=true" >> $GITHUB_OUTPUT + fi + + - name: Upload latest meta.json artifact + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + with: + name: latest-meta.json + path: latest-meta.json + + update-fleetd-base-pkg: + needs: [check-for-fleetd-component-updates] + if: needs.check-for-fleetd-component-updates.outputs.update_needed == 'true' + runs-on: macos-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout code needed for R2 upload + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + sparse-checkout: | + .github/actions/r2-upload/action.yml + .github/scripts/rclone-install.sh + sparse-checkout-cone-mode: false + + - name: Install fleetctl + run: npm install -g fleetctl + + - name: Import package signing keys + env: + APPLE_INSTALLER_CERTIFICATE: ${{ secrets.APPLE_INSTALLER_CERTIFICATE }} + APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + echo "$APPLE_INSTALLER_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security import certificate.p12 -k build.keychain -P $APPLE_INSTALLER_CERTIFICATE_PASSWORD -T /usr/bin/productsign + security set-key-partition-list -S apple-tool:,apple:,productsign: -s -k $KEYCHAIN_PASSWORD build.keychain + security find-identity -vv + rm certificate.p12 + + - name: Build PKG, sign, and notarize + env: + AC_USERNAME: ${{ secrets.APPLE_USERNAME }} + AC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + AC_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + PACKAGE_SIGNING_IDENTITY_SHA1: D52080FD1F0941DE31346F06DA0F08AED6FACBBF + run: | + fleetctl package --type pkg --fleet-desktop --use-system-configuration --sign-identity $PACKAGE_SIGNING_IDENTITY_SHA1 --notarize + mv fleet-osquery*.pkg fleetd-base.pkg + + - name: Upload package + uses: ./.github/actions/r2-upload + with: + filename: fleetd-base.pkg + + update-fleetd-base-msi: + needs: [check-for-fleetd-component-updates] + if: needs.check-for-fleetd-component-updates.outputs.update_needed == 'true' + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout code needed for R2 upload + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + sparse-checkout: | + .github/actions/r2-upload/action.yml + .github/scripts/rclone-install.sh + sparse-checkout-cone-mode: false + + - name: Install fleetctl + run: npm install -g fleetctl + + - name: Build MSI + run: | + fleetctl package --type msi --fleet-desktop --fleet-url dummy --enroll-secret dummy + mv fleet-osquery*.msi fleetd-base.msi + + - name: Upload package + uses: ./.github/actions/r2-upload + with: + filename: fleetd-base.msi + + update-meta-json: + needs: [update-fleetd-base-pkg, update-fleetd-base-msi] + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@63c24ba6bd7ba022e95695ff85de572c04a18142 # v2.7.0 + with: + egress-policy: audit + + - name: Checkout code needed for R2 upload + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + sparse-checkout: | + .github/actions/r2-upload/action.yml + .github/scripts/rclone-install.sh + sparse-checkout-cone-mode: false + + - name: Download latest-meta.json artifact + uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4 + with: + name: latest-meta.json + + - name: Rename latest-meta.json to meta.json + run: mv latest-meta.json meta.json + + - name: Upload meta.json + uses: ./.github/actions/r2-upload + with: + filename: meta.json From 373e9153b7b22243384083864e92c2fbcebb6e31 Mon Sep 17 00:00:00 2001 From: Michael Pinto <147431865+pintomi1989@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:39:28 -0400 Subject: [PATCH 67/83] Add 2023 penetration test (#18375) Co-authored-by: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> --- .../2023-06-09-fleet-penetration-test.pdf | Bin 0 -> 262418 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/files/2023-06-09-fleet-penetration-test.pdf diff --git a/docs/files/2023-06-09-fleet-penetration-test.pdf b/docs/files/2023-06-09-fleet-penetration-test.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9df3952225e27c81827869a410f0e7a9b3c00ccd GIT binary patch literal 262418 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%0DWO0FoTCn8t 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 literal 0 HcmV?d00001 From 05719633a182c65fe843a05f42aaa8c648b53ee9 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 29 Apr 2024 16:43:15 -0300 Subject: [PATCH 68/83] add consistent MDM host lifecycle management (#18510) The mantra for MDM lifecycle events is: > - Noah: When MDM is turned on, install fleetd, bootstrap package (if DEP), > and profiles. Don't clear host vitals (everything you see on the Host > details page) > - Noah: On re-enrollment, don't clear host vitals. > - Noah: On lock and wipe, don't clear host vitals. > - Noah: On delete, clear host vitals. This addresses issues: - https://github.com/fleetdm/fleet/issues/17243 - https://github.com/fleetdm/fleet/issues/17481 - https://github.com/fleetdm/fleet/issues/17292 - https://github.com/fleetdm/fleet/issues/18030 - https://github.com/fleetdm/fleet/issues/18031 --- changes/hosts-lifecycle | 1 + pkg/mdm/mdmtest/apple.go | 10 +- pkg/mdm/mdmtest/windows.go | 22 +- server/datastore/mysql/apple_mdm.go | 160 ++-- server/datastore/mysql/apple_mdm_test.go | 70 +- server/datastore/mysql/hosts.go | 13 +- server/datastore/mysql/mdm.go | 105 +-- server/datastore/mysql/mdm_test.go | 470 +++++++++-- server/fleet/apple_mdm.go | 9 - server/fleet/datastore.go | 15 +- server/mdm/lifecycle/lifecycle.go | 272 +++++++ server/mdm/lifecycle/lifecycle_test.go | 61 ++ server/mock/datastore_mock.go | 36 +- server/service/apple_mdm.go | 212 ++--- server/service/apple_mdm_test.go | 94 ++- server/service/handler.go | 4 +- server/service/hosts.go | 60 +- server/service/integration_core_test.go | 26 +- server/service/integration_mdm_ddm_test.go | 3 + .../service/integration_mdm_lifecycle_test.go | 762 ++++++++++++++++++ .../service/integration_mdm_profiles_test.go | 12 + server/service/integration_mdm_test.go | 162 +--- server/service/microsoft_mdm.go | 15 + server/service/testing_utils.go | 7 +- 24 files changed, 1968 insertions(+), 633 deletions(-) create mode 100644 changes/hosts-lifecycle create mode 100644 server/mdm/lifecycle/lifecycle.go create mode 100644 server/mdm/lifecycle/lifecycle_test.go create mode 100644 server/service/integration_mdm_lifecycle_test.go diff --git a/changes/hosts-lifecycle b/changes/hosts-lifecycle new file mode 100644 index 0000000000..9c4876c678 --- /dev/null +++ b/changes/hosts-lifecycle @@ -0,0 +1 @@ +* Improved handling of different scenarios and edge cases when hosts turn on/off MDM. diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index a2c2f46e33..d4463575d4 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -135,7 +135,7 @@ func NewTestMDMClientAppleDEP(serverURL string, depURLToken string, opts ...Test return &c } -// NewTestMDMClientDEP will create a simulated device that will not fetch the enrollment +// NewTestMDMClientAppleDirect will create a simulated device that will not fetch the enrollment // profile from Fleet. The enrollment information is to be provided in the enrollInfo. func NewTestMDMClientAppleDirect(enrollInfo AppleEnrollInfo, opts ...TestMDMAppleClientOption) *TestAppleMDMClient { c := TestAppleMDMClient{ @@ -151,6 +151,14 @@ func NewTestMDMClientAppleDirect(enrollInfo AppleEnrollInfo, opts ...TestMDMAppl return &c } +func (c *TestAppleMDMClient) SetDesktopToken(tok string) { + c.desktopURLToken = tok +} + +func (c *TestAppleMDMClient) SetDEPToken(tok string) { + c.depURLToken = tok +} + // Enroll runs the MDM enroll protocol on the simulated device. func (c *TestAppleMDMClient) Enroll() error { switch { diff --git a/pkg/mdm/mdmtest/windows.go b/pkg/mdm/mdmtest/windows.go index 91bd5d2319..fc504d1bca 100644 --- a/pkg/mdm/mdmtest/windows.go +++ b/pkg/mdm/mdmtest/windows.go @@ -22,8 +22,8 @@ import ( type TestWindowsMDMClient struct { // DeviceID identifies a MDM enrollment, sent and managed by the device. DeviceID string - // hardwareID identifies a device. - hardwareID string + // HardwareID identifies a device. + HardwareID string // fleetServerURL is the URL of the Fleet server, used to ping the MDM endpoints. fleetServerURL string // debug enables debug logging of request/responses. @@ -31,8 +31,8 @@ type TestWindowsMDMClient struct { // enrollmentType is used to simulate different Windows enrollment // types (programatic, automatic.) enrollmentType fleet.WindowsMDMEnrollmentType - // tokenIdentifier is used for authentication during the programmatic enrollment. - tokenIdentifier string + // TokenIdentifier is used for authentication during the programmatic enrollment. + TokenIdentifier string // lastManagementResp tracks the last response we received from the server. lastManagementResp *fleet.SyncML // queuedCommandResponses tracks the commands that will be sent next @@ -57,8 +57,8 @@ func NewTestMDMClientWindowsProgramatic(serverURL string, orbitNodeKey string, o fleetServerURL: serverURL, DeviceID: uuid.NewString(), enrollmentType: fleet.WindowsMDMProgrammaticEnrollmentType, - tokenIdentifier: orbitNodeKey, - hardwareID: uuid.NewString(), + TokenIdentifier: orbitNodeKey, + HardwareID: uuid.NewString(), } for _, fn := range opts { fn(&c) @@ -71,8 +71,8 @@ func NewTestMDMClientWindowsAutomatic(serverURL string, email string, opts ...Te fleetServerURL: serverURL, DeviceID: uuid.NewString(), enrollmentType: fleet.WindowsMDMAutomaticEnrollmentType, - tokenIdentifier: email, - hardwareID: uuid.NewString(), + TokenIdentifier: email, + HardwareID: uuid.NewString(), } for _, fn := range opts { fn(&c) @@ -319,7 +319,7 @@ YioVozr1IWYySwWVzMf/SUwKZkKJCAJmSVcixE+4kxPkyPGyauIrN3wWC0zb+mjF false - ` + c.hardwareID + ` + ` + c.HardwareID + ` en-US @@ -487,7 +487,7 @@ func (c *TestWindowsMDMClient) getToken() (binarySecToken string, tokenValueType switch c.enrollmentType { case fleet.WindowsMDMAutomaticEnrollmentType: claims := &jwt.MapClaims{ - "upn": c.tokenIdentifier, + "upn": c.TokenIdentifier, "tid": "tenant_id", "unique_name": "foo_bar", "scp": "mdm_delegation", @@ -504,7 +504,7 @@ func (c *TestWindowsMDMClient) getToken() (binarySecToken string, tokenValueType case fleet.WindowsMDMProgrammaticEnrollmentType: var err error tokenValueType = syncml.BinarySecurityDeviceEnroll - binarySecToken, err = fleet.GetEncodedBinarySecurityToken(c.enrollmentType, c.tokenIdentifier) + binarySecToken, err = fleet.GetEncodedBinarySecurityToken(c.enrollmentType, c.TokenIdentifier) if err != nil { return "", "", fmt.Errorf("generating encoded security token: %w", err) } diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index f6253d2fd4..db2e0df8db 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -704,7 +704,7 @@ WHERE return devices, nil } -func (ds *Datastore) IngestMDMAppleDeviceFromCheckin(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { +func (ds *Datastore) MDMAppleUpsertHost(ctx context.Context, mdmHost *fleet.Host) error { appCfg, err := ds.AppConfig(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config") @@ -717,20 +717,20 @@ func (ds *Datastore) IngestMDMAppleDeviceFromCheckin(ctx context.Context, mdmHos func ingestMDMAppleDeviceFromCheckinDB( ctx context.Context, tx sqlx.ExtContext, - mdmHost fleet.MDMAppleHostDetails, + mdmHost *fleet.Host, logger log.Logger, appCfg *fleet.AppConfig, ) error { - if mdmHost.SerialNumber == "" { + if mdmHost.HardwareSerial == "" { return ctxerr.New(ctx, "ingest mdm apple host from checkin expected device serial number but got empty string") } - if mdmHost.UDID == "" { + if mdmHost.UUID == "" { return ctxerr.New(ctx, "ingest mdm apple host from checkin expected unique device id but got empty string") } // MDM is necessarily enabled if this gets called, always pass true for that // parameter. - matchID, _, err := matchHostDuringEnrollment(ctx, tx, mdmEnroll, true, "", mdmHost.UDID, mdmHost.SerialNumber) + matchID, _, err := matchHostDuringEnrollment(ctx, tx, mdmEnroll, true, "", mdmHost.UUID, mdmHost.HardwareSerial) switch { case errors.Is(err, sql.ErrNoRows): return insertMDMAppleHostDB(ctx, tx, mdmHost, logger, appCfg) @@ -747,7 +747,7 @@ func updateMDMAppleHostDB( ctx context.Context, tx sqlx.ExtContext, hostID uint, - mdmHost fleet.MDMAppleHostDetails, + mdmHost *fleet.Host, appCfg *fleet.AppConfig, ) error { updateStmt := ` @@ -763,13 +763,13 @@ func updateMDMAppleHostDB( if _, err := tx.ExecContext( ctx, updateStmt, - mdmHost.SerialNumber, - mdmHost.UDID, - mdmHost.Model, + mdmHost.HardwareSerial, + mdmHost.UUID, + mdmHost.HardwareModel, "darwin", 1, // Set osquery_host_id to the device UUID only if it is not already set. - mdmHost.UDID, + mdmHost.UUID, hostID, ); err != nil { return ctxerr.Wrap(ctx, err, "update mdm apple host") @@ -790,7 +790,7 @@ func updateMDMAppleHostDB( func insertMDMAppleHostDB( ctx context.Context, tx sqlx.ExtContext, - mdmHost fleet.MDMAppleHostDetails, + mdmHost *fleet.Host, logger log.Logger, appCfg *fleet.AppConfig, ) error { @@ -809,13 +809,13 @@ func insertMDMAppleHostDB( res, err := tx.ExecContext( ctx, insertStmt, - mdmHost.SerialNumber, - mdmHost.UDID, - mdmHost.Model, + mdmHost.HardwareSerial, + mdmHost.UUID, + mdmHost.HardwareModel, "darwin", "2000-01-01 00:00:00", "2000-01-01 00:00:00", - mdmHost.UDID, + mdmHost.UUID, 1, ) if err != nil { @@ -829,17 +829,18 @@ func insertMDMAppleHostDB( if id < 1 { return ctxerr.Wrap(ctx, err, "ingest mdm apple host unexpected last insert id") } - host := fleet.Host{ID: uint(id), HardwareModel: mdmHost.Model, HardwareSerial: mdmHost.SerialNumber} - if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, host); err != nil { + mdmHost.ID = uint(id) + + if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, *mdmHost); err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names") } - if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, host); err != nil { + if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, *mdmHost); err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership") } - if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings, false, host.ID); err != nil { + if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings, false, mdmHost.ID); err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") } return nil @@ -1125,28 +1126,58 @@ func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext return nil } -func (ds *Datastore) deleteMDMAppleProfilesForHost(ctx context.Context, tx sqlx.ExtContext, uuid string) error { - _, err := tx.ExecContext(ctx, ` - DELETE FROM host_mdm_apple_profiles - WHERE host_uuid = ?`, uuid) - if err != nil { - return ctxerr.Wrap(ctx, err, "removing all profiles from host") +// deleteMDMOSCustomSettingsForHost deletes configuration profiles and +// declarations for a host based on its platform. +func (ds *Datastore) deleteMDMOSCustomSettingsForHost(ctx context.Context, tx sqlx.ExtContext, uuid, platform string) error { + + tableMap := map[string][]string{ + "darwin": {"host_mdm_apple_profiles", "host_mdm_apple_declarations"}, + "windows": {"host_mdm_windows_profiles"}, } + + tables, ok := tableMap[platform] + if !ok { + return ctxerr.Errorf(ctx, "unsupported platform %s", platform) + } + + for _, table := range tables { + _, err := tx.ExecContext(ctx, fmt.Sprintf(` + DELETE FROM %s + WHERE host_uuid = ?`, table), uuid) + if err != nil { + return ctxerr.Wrapf(ctx, err, "removing all %s from host %s", table, uuid) + } + } + return nil } -func (ds *Datastore) UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error { - return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - var hostID uint - row := tx.QueryRowxContext(ctx, `SELECT id FROM hosts WHERE uuid = ?`, uuid) - err := row.Scan(&hostID) +func (ds *Datastore) MDMTurnOff(ctx context.Context, uuid string) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var host fleet.Host + err := sqlx.GetContext( + ctx, tx, &host, + `SELECT id, platform FROM hosts WHERE uuid = ? LIMIT 1`, uuid, + ) if err != nil { - return ctxerr.Wrap(ctx, err, "getting host id from UUID") + return ctxerr.Wrap(ctx, err, "getting host info from UUID") } - // NOTE: set installed_from_dep = 0 so DEP host will not be counted as pending after it unenrolls. + if host.Platform != "darwin" && host.Platform != "windows" { + return ctxerr.Errorf(ctx, "unsupported host platform: %s", host.Platform) + } + + // NOTE: set installed_from_dep = 0 so DEP host will not be + // counted as pending after it unenrolls. _, err = tx.ExecContext(ctx, ` - UPDATE host_mdm SET enrolled = 0, installed_from_dep = 0, server_url = '', mdm_id = NULL WHERE host_id = ?`, hostID) + UPDATE host_mdm + SET + enrolled = 0, + installed_from_dep = 0, + server_url = '', + mdm_id = NULL + WHERE + host_id = ?`, host.ID) if err != nil { return ctxerr.Wrap(ctx, err, "clearing host_mdm for host") } @@ -1155,18 +1186,16 @@ func (ds *Datastore) UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid str // host manually, the device won't Acknowledge any more requests (eg: // to delete profiles) and profiles are automatically removed on // unenrollment. - if err := ds.deleteMDMAppleProfilesForHost(ctx, tx, uuid); err != nil { + if err := ds.deleteMDMOSCustomSettingsForHost(ctx, tx, uuid, host.Platform); err != nil { return ctxerr.Wrap(ctx, err, "deleting profiles for host") } - _, err = tx.ExecContext(ctx, ` - DELETE FROM host_disk_encryption_keys - WHERE host_id = ?`, hostID) - if err != nil { - return ctxerr.Wrap(ctx, err, "removing all profiles from host") - } + // NOTE: intentionally keeping disk encryption keys and bootstrap + // package information. - return nil + // request a refetch to update any eventually consistent stale information. + err = updateHostRefetchRequestedDB(ctx, tx, host.ID, true) + return ctxerr.Wrap(ctx, err, "setting host refetch requested") }) } @@ -3368,29 +3397,52 @@ WHERE return nil } -func (ds *Datastore) ResetMDMAppleEnrollment(ctx context.Context, hostUUID string) error { +func (ds *Datastore) MDMResetEnrollment(ctx context.Context, hostUUID string) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - // it's okay if we didn't update any rows, `nano_enrollments` entries - // are created on `TokenUpdate`, and this function is called on - // `Authenticate` to make sure we start on a clean state if a host is - // re-enrolling. - _, err := tx.ExecContext(ctx, `UPDATE nano_enrollments SET token_update_tally = 0 WHERE id = ?`, hostUUID) + var host fleet.Host + err := sqlx.GetContext( + ctx, tx, &host, + `SELECT id, platform FROM hosts WHERE uuid = ? LIMIT 1`, hostUUID, + ) if err != nil { - return ctxerr.Wrap(ctx, err, "resetting nano_enrollments") + return ctxerr.Wrap(ctx, err, "getting host info from UUID") + } + + if host.Platform != "darwin" && host.Platform != "windows" { + return ctxerr.Errorf(ctx, "unsupported host platform: %s", host.Platform) } // Deleting profiles from this table will cause all profiles to // be re-delivered on the next cron run. - if err := ds.deleteMDMAppleProfilesForHost(ctx, tx, hostUUID); err != nil { + if err := ds.deleteMDMOSCustomSettingsForHost(ctx, tx, hostUUID, host.Platform); err != nil { return ctxerr.Wrap(ctx, err, "resetting profiles status") } - // Deleting the matching entry on this table will cause - // the aggregate report to show this host as 'pending' to - // install the bootstrap package. - _, err = tx.ExecContext(ctx, `DELETE FROM host_mdm_apple_bootstrap_packages WHERE host_uuid = ?`, hostUUID) + // Delete any stored disk encryption keys. This covers cases + // where hosts re-enroll without sending a CheckOut message + // first, for example: + // + // - IT admin wiping the host locally + // - Host restoring from a back-up + // + // This also means that somebody running `sudo profiles renew + // --type enrollment` will report disk encryption as "pending" + // for a short period of time. + _, err = tx.ExecContext(ctx, ` + DELETE FROM host_disk_encryption_keys + WHERE host_id = ?`, host.ID) if err != nil { - return ctxerr.Wrap(ctx, err, "resetting host_mdm_apple_bootstrap_packages") + return ctxerr.Wrap(ctx, err, "resetting disk encryption key information for host") + } + + if host.Platform == "darwin" { + // Deleting the matching entry on this table will cause + // the aggregate report to show this host as 'pending' to + // install the bootstrap package. + _, err = tx.ExecContext(ctx, `DELETE FROM host_mdm_apple_bootstrap_packages WHERE host_uuid = ?`, hostUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "resetting host_mdm_apple_bootstrap_packages") + } } return nil diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 7a8a6daad1..43ec3a7812 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -66,7 +66,7 @@ func TestMDMApple(t *testing.T) { {"TestMDMAppleDefaultSetupAssistant", testMDMAppleDefaultSetupAssistant}, {"TestSetVerifiedMacOSProfiles", testSetVerifiedMacOSProfiles}, {"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash}, - {"TestResetMDMAppleEnrollment", testResetMDMAppleEnrollment}, + {"TestMDMAppleResetEnrollment", testMDMAppleResetEnrollment}, {"TestMDMAppleDeleteHostDEPAssignments", testMDMAppleDeleteHostDEPAssignments}, {"LockUnlockWipeMacOS", testLockUnlockWipeMacOS}, {"ScreenDEPAssignProfileSerialsForCooldown", testScreenDEPAssignProfileSerialsForCooldown}, @@ -746,9 +746,9 @@ func testIngestMDMAppleHostAlreadyExistsInFleet(t *testing.T, ds *Datastore) { require.Equal(t, testSerial, hosts[0].HardwareSerial) require.Equal(t, testUUID, hosts[0].UUID) - err = ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -784,9 +784,9 @@ func testIngestMDMNonDarwinHostAlreadyExistsInFleet(t *testing.T, ds *Datastore) require.Equal(t, testSerial, hosts[0].HardwareSerial) require.Equal(t, testUUID, hosts[0].UUID) - err = ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -825,9 +825,9 @@ func testIngestMDMAppleIngestAfterDEPSync(t *testing.T, ds *Datastore) { checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, testModel) // now simulate the initial MDM checkin by that same host - err = ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -844,10 +844,10 @@ func testIngestMDMAppleCheckinBeforeDEPSync(t *testing.T, ds *Datastore) { testModel := "MacBook Pro" // ingest host on initial mdm checkin - err := ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, - Model: testModel, + err := ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, + HardwareModel: testModel, }) require.NoError(t, err) @@ -875,9 +875,9 @@ func testIngestMDMAppleCheckinMultipleIngest(t *testing.T, ds *Datastore) { testSerial := "test-serial" testUUID := "test-uuid" - err := ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err := ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -886,9 +886,9 @@ func testIngestMDMAppleCheckinMultipleIngest(t *testing.T, ds *Datastore) { require.Equal(t, testUUID, hosts[0].UUID) // duplicate Authenticate request has no effect - err = ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -901,9 +901,9 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { ctx := context.Background() testSerial := "test-serial" testUUID := "test-uuid" - err := ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{ - UDID: testUUID, - SerialNumber: testSerial, + err := ds.MDMAppleUpsertHost(ctx, &fleet.Host{ + UUID: testUUID, + HardwareSerial: testSerial, }) require.NoError(t, err) @@ -946,7 +946,7 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, 1, count) - err = ds.UpdateHostTablesOnMDMUnenroll(ctx, testUUID) + err = ds.MDMTurnOff(ctx, testUUID) require.NoError(t, err) err = sqlx.GetContext(context.Background(), ds.reader(context.Background()), &count, `SELECT COUNT(*) FROM host_mdm WHERE host_id = ?`, testUUID) @@ -957,8 +957,8 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Empty(t, hostProfs) key, err = ds.GetHostDiskEncryptionKey(ctx, hostID) - require.ErrorIs(t, err, sql.ErrNoRows) - require.Nil(t, key) + require.NoError(t, err) + require.NotNil(t, key) } func expectAppleProfiles( @@ -2202,7 +2202,7 @@ func testMDMAppleHostsProfilesStatus(t *testing.T, ds *Datastore) { // hosts[6] deletes all its profiles tx, err := ds.writer(ctx).BeginTxx(ctx, nil) require.NoError(t, err) - require.NoError(t, ds.deleteMDMAppleProfilesForHost(ctx, tx, hosts[6].UUID)) + require.NoError(t, ds.deleteMDMOSCustomSettingsForHost(ctx, tx, hosts[6].UUID, "darwin")) require.NoError(t, tx.Commit()) pendingHosts := append(hosts[2:6:6], hosts[7:]...) res, err = ds.GetMDMAppleProfilesSummary(ctx, nil) // get summary for profiles with no team @@ -2534,7 +2534,7 @@ func testDeleteMDMAppleProfilesForHost(t *testing.T, ds *Datastore) { tx, err := ds.writer(ctx).BeginTxx(ctx, nil) require.NoError(t, err) - require.NoError(t, ds.deleteMDMAppleProfilesForHost(ctx, tx, h.UUID)) + require.NoError(t, ds.deleteMDMOSCustomSettingsForHost(ctx, tx, h.UUID, "darwin")) require.NoError(t, tx.Commit()) require.NoError(t, err) gotProfs, err = ds.GetHostMDMAppleProfiles(ctx, h.UUID) @@ -4215,7 +4215,7 @@ func TestHostDEPAssignments(t *testing.T) { require.True(t, *h.DEPAssignedToFleet) // simulate MDM unenroll - require.NoError(t, ds.UpdateHostTablesOnMDMUnenroll(ctx, depUUID)) + require.NoError(t, ds.MDMTurnOff(ctx, depUUID)) // host MDM row is set to defaults on unenrollment getHostResp, err = ds.Host(ctx, testHost.ID) @@ -4295,7 +4295,7 @@ func TestHostDEPAssignments(t *testing.T) { manualOrbitNodeKey := "manual-orbit-node-key" manualDeviceToken := "manual-device-token" - err = ds.IngestMDMAppleDeviceFromCheckin(ctx, fleet.MDMAppleHostDetails{SerialNumber: manualSerial, UDID: manualUUID}) + err = ds.MDMAppleUpsertHost(ctx, &fleet.Host{HardwareSerial: manualSerial, UUID: manualUUID}) require.NoError(t, err) var manualHostID uint @@ -4413,7 +4413,7 @@ func testMDMAppleConfigProfileHash(t *testing.T, ds *Datastore) { } } -func testResetMDMAppleEnrollment(t *testing.T, ds *Datastore) { +func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) { ctx := context.Background() host, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "test-host1-name", @@ -4427,7 +4427,7 @@ func testResetMDMAppleEnrollment(t *testing.T, ds *Datastore) { // try with a host that doesn't have a matching entry // in nano_enrollments - err = ds.ResetMDMAppleEnrollment(ctx, host.UUID) + err = ds.MDMResetEnrollment(ctx, host.UUID) require.NoError(t, err) // add a matching entry in the nano table @@ -4484,13 +4484,9 @@ func testResetMDMAppleEnrollment(t *testing.T, ds *Datastore) { require.EqualValues(t, 1, sum.Installed) // reset the enrollment - err = ds.ResetMDMAppleEnrollment(ctx, host.UUID) + err = ds.MDMResetEnrollment(ctx, host.UUID) require.NoError(t, err) - enrollment, err = ds.GetNanoMDMEnrollment(ctx, host.UUID) - require.NoError(t, err) - require.Zero(t, enrollment.TokenUpdateTally) - gotProfs, err = ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) require.Empty(t, gotProfs) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index ce86ef3f4e..56474197b4 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -4345,12 +4345,15 @@ func (ds *Datastore) UpdateHostOsqueryIntervals(ctx context.Context, id uint, in // UpdateHostRefetchRequested updates a host's refetch requested field. func (ds *Datastore) UpdateHostRefetchRequested(ctx context.Context, id uint, value bool) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + return updateHostRefetchRequestedDB(ctx, tx, id, value) + }) +} + +func updateHostRefetchRequestedDB(ctx context.Context, tx sqlx.ExtContext, id uint, value bool) error { sqlStatement := `UPDATE hosts SET refetch_requested = ? WHERE id = ?` - _, err := ds.writer(ctx).ExecContext(ctx, sqlStatement, value, id) - if err != nil { - return ctxerr.Wrapf(ctx, err, "update host %d refetch_requested", id) - } - return nil + _, err := tx.ExecContext(ctx, sqlStatement, value, id) + return ctxerr.Wrapf(ctx, err, "update host %d refetch_requested", id) } // UpdateHostRefetchCriticalQueriesUntil updates a host's refetch critical queries until field. diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 2ace200a05..691db6fecf 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -307,11 +307,22 @@ ORDER BY return labels, nil } +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) + }) +} + // Note that team ID 0 is used for profiles that apply to hosts in no team // (i.e. pass 0 in that case as part of the teamIDs slice). Only one of the // slice arguments can have values. -func (ds *Datastore) BulkSetPendingMDMHostProfiles( +func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( ctx context.Context, + tx sqlx.ExtContext, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, ) error { @@ -431,60 +442,58 @@ WHERE } - return ds.withTx(ctx, func(tx sqlx.ExtContext) error { - // TODO: this could be optimized to avoid querying for platform when - // profileIDs or profileUUIDs are provided. - 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") - } - if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") - } + // TODO: this could be optimized to avoid querying for platform when + // profileIDs or profileUUIDs are provided. + 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") } + if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") + } + } - var macHosts []string - var winHosts []string - for _, h := range hosts { - switch h.Platform { - case "darwin": - macHosts = append(macHosts, h.UUID) - case "windows": - winHosts = append(winHosts, h.UUID) - default: - level.Debug(ds.logger).Log( - "msg", "tried to set profile status for a host with unsupported platform", - "platform", h.Platform, - "host_uuid", h.UUID, - ) - } + var macHosts []string + var winHosts []string + for _, h := range hosts { + switch h.Platform { + case "darwin": + macHosts = append(macHosts, h.UUID) + case "windows": + winHosts = append(winHosts, h.UUID) + default: + level.Debug(ds.logger).Log( + "msg", "tried to set profile status for a host with unsupported platform", + "platform", h.Platform, + "host_uuid", h.UUID, + ) } + } - if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, macHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") - } + if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, macHosts); err != nil { + return 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") - } + if err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") + } - const defaultBatchSize = 1000 - batchSize := defaultBatchSize - if ds.testUpsertMDMDesiredProfilesBatchSize > 0 { - batchSize = ds.testUpsertMDMDesiredProfilesBatchSize - } - // TODO(roberto): this method currently sets the state of all - // declarations for all hosts. I don't see an immediate concern - // (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") - } + const defaultBatchSize = 1000 + batchSize := defaultBatchSize + if ds.testUpsertMDMDesiredProfilesBatchSize > 0 { + batchSize = ds.testUpsertMDMDesiredProfilesBatchSize + } + // TODO(roberto): this method currently sets the state of all + // declarations for all hosts. I don't see an immediate concern + // (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") + } - return nil - }) + return nil } func (ds *Datastore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify, toFail, toRetry []string) error { diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 3f4988d13e..a1de72e56e 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -38,7 +38,10 @@ func TestMDMShared(t *testing.T) { {"TestBulkSetPendingMDMHostProfiles", testBulkSetPendingMDMHostProfiles}, {"TestBulkSetPendingMDMHostProfilesBatch2", testBulkSetPendingMDMHostProfilesBatch2}, {"TestBulkSetPendingMDMHostProfilesBatch3", testBulkSetPendingMDMHostProfilesBatch3}, - {"TestGetHostMDMProfilesExpectedForVerification", testGetHostMDMProfilesExpectedForVerification}, + { + "TestGetHostMDMProfilesExpectedForVerification", + testGetHostMDMProfilesExpectedForVerification, + }, {"TestBatchSetProfileLabelAssociations", testBatchSetProfileLabelAssociations}, {"TestBatchSetProfilesTransactionError", testBatchSetMDMProfilesTransactionError}, {"TestMDMEULA", testMDMEULA}, @@ -87,9 +90,16 @@ func testMDMCommands(t *testing.T, ds *Datastore) { } err = ds.MDMWindowsInsertEnrolledDevice(ctx, windowsEnrollment) require.NoError(t, err) - err = ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, windowsEnrollment.HostUUID, windowsEnrollment.MDMDeviceID) + err = ds.UpdateMDMWindowsEnrollmentsHostUUID( + ctx, + windowsEnrollment.HostUUID, + windowsEnrollment.MDMDeviceID, + ) require.NoError(t, err) - windowsEnrollment, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, windowsEnrollment.MDMDeviceID) + windowsEnrollment, err = ds.MDMWindowsGetEnrolledDeviceWithDeviceID( + ctx, + windowsEnrollment.MDMDeviceID, + ) require.NoError(t, err) // enroll a macOS device @@ -104,7 +114,11 @@ func testMDMCommands(t *testing.T, ds *Datastore) { nanoEnroll(t, ds, macH, false) // no commands => no results - cmds, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{}) + cmds, err = ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{}, + ) require.NoError(t, err) require.Empty(t, cmds) @@ -118,7 +132,11 @@ func testMDMCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) // we get one result - cmds, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{}) + cmds, err = ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{}, + ) require.NoError(t, err) require.Len(t, cmds, 1) require.Equal(t, winCmd.CommandUUID, cmds[0].CommandUUID) @@ -160,12 +178,25 @@ func testMDMCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - res, err := tx.ExecContext(ctx, `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, windowsEnrollment.ID, "") + res, err := tx.ExecContext( + ctx, + `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`, + windowsEnrollment.ID, + "", + ) if err != nil { return err } resID, _ := res.LastInsertId() - _, err = tx.ExecContext(ctx, `INSERT INTO windows_mdm_command_results (enrollment_id, command_uuid, raw_result, status_code, response_id) VALUES (?, ?, ?, ?, ?)`, windowsEnrollment.ID, winCmd.CommandUUID, "", "200", resID) + _, err = tx.ExecContext( + ctx, + `INSERT INTO windows_mdm_command_results (enrollment_id, command_uuid, raw_result, status_code, response_id) VALUES (?, ?, ?, ?, ?)`, + windowsEnrollment.ID, + winCmd.CommandUUID, + "", + "200", + resID, + ) return err }) @@ -228,8 +259,12 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { []*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")}, []*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")}, ptr.Uint(1), - []*fleet.MDMAppleConfigProfile{withTeamIDApple(configProfileForTest(t, "N1", "I1", "a"), 1)}, - []*fleet.MDMWindowsConfigProfile{withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1)}, + []*fleet.MDMAppleConfigProfile{ + withTeamIDApple(configProfileForTest(t, "N1", "I1", "a"), 1), + }, + []*fleet.MDMWindowsConfigProfile{ + withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1), + }, []*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)}, ) @@ -378,9 +413,15 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { // add fleet-managed Windows profiles for the team and globally for name := range mdm_types.FleetReservedProfileNames() { - _, err = ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, TeamID: &team.ID, SyncML: winProf}) + _, err = ds.NewMDMWindowsConfigProfile( + ctx, + fleet.MDMWindowsConfigProfile{Name: name, TeamID: &team.ID, SyncML: winProf}, + ) require.NoError(t, err) - _, err = ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, TeamID: nil, SyncML: winProf}) + _, err = ds.NewMDMWindowsConfigProfile( + ctx, + fleet.MDMWindowsConfigProfile{Name: name, TeamID: nil, SyncML: winProf}, + ) require.NoError(t, err) } @@ -398,7 +439,10 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { // create a mac profile for global and a Windows profile for team profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("A", "A", 0)) require.NoError(t, err) - profB, err := ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "B", TeamID: &team.ID, SyncML: winProf}) + profB, err := ds.NewMDMWindowsConfigProfile( + ctx, + fleet.MDMWindowsConfigProfile{Name: "B", TeamID: &team.ID, SyncML: winProf}, + ) require.NoError(t, err) // get global profiles returns the mac one @@ -449,7 +493,11 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { _, err = ds.NewMDMAppleConfigProfile(ctx, acp) require.NoError(t, err) - wcp := fleet.MDMWindowsConfigProfile{Name: string(rune('C' + inc + 2)), TeamID: nil, SyncML: winProf} + wcp := fleet.MDMWindowsConfigProfile{ + Name: string(rune('C' + inc + 2)), + TeamID: nil, + SyncML: winProf, + } if i == 0 { wcp.Labels = []fleet.ConfigurationProfileLabel{ {LabelName: labels[4].Name, LabelID: labels[4].ID}, @@ -459,7 +507,11 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { _, err = ds.NewMDMWindowsConfigProfile(ctx, wcp) require.NoError(t, err) - wcp = fleet.MDMWindowsConfigProfile{Name: string(rune('C' + inc + 3)), TeamID: &team.ID, SyncML: winProf} + wcp = fleet.MDMWindowsConfigProfile{ + Name: string(rune('C' + inc + 3)), + TeamID: &team.ID, + SyncML: winProf, + } if i == 0 { wcp.Labels = []fleet.ConfigurationProfileLabel{ {LabelName: labels[6].Name, LabelID: labels[6].ID}, @@ -499,33 +551,165 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { wantNames []string wantMeta fleet.PaginationMetadata }{ - {"all global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, []string{"A", "C", "E", "G", "I", "K", "M"}, fleet.PaginationMetadata{}}, - {"all team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, []string{"B", "D", "F", "H", "J", "L", "N"}, fleet.PaginationMetadata{}}, + { + "all global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, + []string{"A", "C", "E", "G", "I", "K", "M"}, + fleet.PaginationMetadata{}, + }, + { + "all team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, + []string{"B", "D", "F", "H", "J", "L", "N"}, + fleet.PaginationMetadata{}, + }, - {"page 0 per page 2, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2}, []string{"A", "C"}, fleet.PaginationMetadata{HasNextResults: true}}, - {"page 1 per page 2, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1}, []string{"E", "G"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 2 per page 2, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2}, []string{"I", "K"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 3 per page 2, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3}, []string{"M"}, fleet.PaginationMetadata{HasPreviousResults: true}}, - {"page 4 per page 2, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4}, []string{}, fleet.PaginationMetadata{HasPreviousResults: true}}, + { + "page 0 per page 2, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2}, + []string{"A", "C"}, + fleet.PaginationMetadata{HasNextResults: true}, + }, + { + "page 1 per page 2, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1}, + []string{"E", "G"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 2 per page 2, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2}, + []string{"I", "K"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 3 per page 2, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3}, + []string{"M"}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, + { + "page 4 per page 2, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4}, + []string{}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, - {"page 0 per page 2, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2}, []string{"B", "D"}, fleet.PaginationMetadata{HasNextResults: true}}, - {"page 1 per page 2, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1}, []string{"F", "H"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 2 per page 2, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2}, []string{"J", "L"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 3 per page 2, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3}, []string{"N"}, fleet.PaginationMetadata{HasPreviousResults: true}}, - {"page 4 per page 2, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4}, []string{}, fleet.PaginationMetadata{HasPreviousResults: true}}, + { + "page 0 per page 2, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2}, + []string{"B", "D"}, + fleet.PaginationMetadata{HasNextResults: true}, + }, + { + "page 1 per page 2, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 1}, + []string{"F", "H"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 2 per page 2, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 2}, + []string{"J", "L"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 3 per page 2, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 3}, + []string{"N"}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, + { + "page 4 per page 2, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 2, Page: 4}, + []string{}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, - {"page 0 per page 3, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3}, []string{"A", "C", "E"}, fleet.PaginationMetadata{HasNextResults: true}}, - {"page 1 per page 3, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1}, []string{"G", "I", "K"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 2 per page 3, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2}, []string{"M"}, fleet.PaginationMetadata{HasPreviousResults: true}}, - {"page 3 per page 3, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3}, []string{}, fleet.PaginationMetadata{HasPreviousResults: true}}, + { + "page 0 per page 3, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3}, + []string{"A", "C", "E"}, + fleet.PaginationMetadata{HasNextResults: true}, + }, + { + "page 1 per page 3, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1}, + []string{"G", "I", "K"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 2 per page 3, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2}, + []string{"M"}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, + { + "page 3 per page 3, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3}, + []string{}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, - {"page 0 per page 3, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3}, []string{"B", "D", "F"}, fleet.PaginationMetadata{HasNextResults: true}}, - {"page 1 per page 3, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1}, []string{"H", "J", "L"}, fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}}, - {"page 2 per page 3, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2}, []string{"N"}, fleet.PaginationMetadata{HasPreviousResults: true}}, - {"page 3 per page 3, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3}, []string{}, fleet.PaginationMetadata{HasPreviousResults: true}}, + { + "page 0 per page 3, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3}, + []string{"B", "D", "F"}, + fleet.PaginationMetadata{HasNextResults: true}, + }, + { + "page 1 per page 3, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 1}, + []string{"H", "J", "L"}, + fleet.PaginationMetadata{HasPreviousResults: true, HasNextResults: true}, + }, + { + "page 2 per page 3, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 2}, + []string{"N"}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, + { + "page 3 per page 3, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: true, PerPage: 3, Page: 3}, + []string{}, + fleet.PaginationMetadata{HasPreviousResults: true}, + }, - {"no metadata, global", nil, fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1}, []string{"E", "G"}, fleet.PaginationMetadata{}}, - {"no metadata, team", &team.ID, fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1}, []string{"F", "H"}, fleet.PaginationMetadata{}}, + { + "no metadata, global", + nil, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1}, + []string{"E", "G"}, + fleet.PaginationMetadata{}, + }, + { + "no metadata, team", + &team.ID, + fleet.ListOptions{OrderKey: "name", IncludeMetadata: false, PerPage: 2, Page: 1}, + []string{"F", "H"}, + fleet.PaginationMetadata{}, + }, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { @@ -788,7 +972,13 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G2w", "L2"), windowsConfigProfileForTest(t, "G3w", "L3"), } - err = ds.BatchSetMDMProfiles(ctx, nil, macGlobalProfiles, winGlobalProfiles, macGlobalDeclarations) + err = ds.BatchSetMDMProfiles( + ctx, + nil, + macGlobalProfiles, + winGlobalProfiles, + macGlobalDeclarations, + ) require.NoError(t, err) macGlobalProfiles, err = ds.ListMDMAppleConfigProfiles(ctx, nil) require.NoError(t, err) @@ -4962,7 +5152,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "labeled_prof") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, + "labeled_prof", + ) }) // Update label with host membership @@ -5035,7 +5231,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "labeled_prof_2") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, + "labeled_prof_2", + ) }) // Update label with host membership @@ -5119,7 +5321,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "broken_label_prof") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, + "broken_label_prof", + ) }) // Update label with host membership @@ -5231,7 +5439,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, "labeled_prof") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, + "labeled_prof", + ) }) // Update label with host membership @@ -5304,7 +5518,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, "labeled_prof_2") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, + "labeled_prof_2", + ) }) // Update label with host membership @@ -5388,7 +5608,13 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, "broken_label_prof") + return sqlx.GetContext( + ctx, + q, + &uid, + `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE name = ?`, + "broken_label_prof", + ) }) // Update label with host membership @@ -5593,11 +5819,17 @@ 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")) + require.NoError( + t, + batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"), + ) wantOtherMac := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID}, } - require.NoError(t, batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin")) + require.NoError( + t, + batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin"), + ) platforms := map[string]string{ "darwin": macOSProfile.ProfileUUID, @@ -5615,7 +5847,11 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { p = "apple" } - query := fmt.Sprintf("SELECT %s_profile_uuid as profile_uuid, label_id, label_name FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ?", p, p) + query := fmt.Sprintf( + "SELECT %s_profile_uuid as profile_uuid, label_id, label_name FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ?", + p, + p, + ) var got []fleet.ConfigurationProfileLabel ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { @@ -5724,7 +5960,12 @@ 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(ctx, tx, []fleet.ConfigurationProfileLabel{{}}, "unsupported") + return batchSetProfileLabelAssociationsDB( + ctx, + tx, + []fleet.ConfigurationProfileLabel{{}}, + "unsupported", + ) }) require.Error(t, err) }) @@ -5745,18 +5986,50 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { {"insert:b", "", ": insert:b"}, {"delete:c", "", "batch set windows profiles: delete obsolete profiles: delete:c"}, {"reselect:d", "", "batch set windows profiles: load newly inserted profiles: reselect:d"}, - {"labels:e", "", "batch set windows profiles: inserting windows profile label associations: labels:e"}, - {"inselect:k", "", "batch set windows profiles: build query to load existing profiles: inselect:k"}, - {"indelete:l", "", "batch set windows profiles: build statement to delete obsolete profiles: indelete:l"}, - {"inreselect:m", "", "batch set windows profiles: build query to load newly inserted profiles: inreselect:m"}, + { + "labels:e", + "", + "batch set windows profiles: inserting windows profile label associations: labels:e", + }, + { + "inselect:k", + "", + "batch set windows profiles: build query to load existing profiles: inselect:k", + }, + { + "indelete:l", + "", + "batch set windows profiles: build statement to delete obsolete profiles: indelete:l", + }, + { + "inreselect:m", + "", + "batch set windows profiles: build query to load newly inserted profiles: inreselect:m", + }, {"", "select:f", "batch set apple profiles: load existing profiles: select:f"}, {"", "insert:g", ": insert:g"}, {"", "delete:h", "batch set apple profiles: delete obsolete profiles: delete:h"}, {"", "reselect:i", "batch set apple profiles: load newly inserted profiles: reselect:i"}, - {"", "labels:j", "batch set apple profiles: inserting apple profile label associations: labels:j"}, - {"", "inselect:n", "batch set apple profiles: build query to load existing profiles: inselect:n"}, - {"", "indelete:o", "batch set apple profiles: build statement to delete obsolete profiles: indelete:o"}, - {"", "inreselect:p", "batch set apple profiles: build query to load newly inserted profiles: inreselect:p"}, + { + "", + "labels:j", + "batch set apple profiles: inserting apple profile label associations: labels:j", + }, + { + "", + "inselect:n", + "batch set apple profiles: build query to load existing profiles: inselect:n", + }, + { + "", + "indelete:o", + "batch set apple profiles: build statement to delete obsolete profiles: indelete:o", + }, + { + "", + "inreselect:p", + "batch set apple profiles: build query to load newly inserted profiles: inreselect:p", + }, } for _, c := range cases { t.Run(c.windowsErr+" "+c.appleErr, func(t *testing.T) { @@ -5929,7 +6202,13 @@ func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) { checkSCEPRenew := func(assoc fleet.SCEPIdentityAssociation, want *string) { var got *string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &got, `SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?`, assoc.HostUUID) + return sqlx.GetContext( + ctx, + q, + &got, + `SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?`, + assoc.HostUUID, + ) }) require.EqualValues(t, want, got) } @@ -5964,7 +6243,11 @@ func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) { checkSCEPRenew(assocs[2], ptr.String("bar")) checkSCEPRenew(assocs[3], ptr.String("bar")) - err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}}, "bar") + err = ds.SetCommandForPendingSCEPRenewal( + ctx, + []fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}}, + "bar", + ) require.ErrorContains(t, err, "this function can only be used to update existing associations") err = ds.CleanSCEPRenewRefs(ctx, "does-not-exist") @@ -5998,21 +6281,34 @@ func testMDMProfilesSummaryAndHostFilters(t *testing.T, ds *Datastore) { } checkListHostsFilterOSSettings := func(t *testing.T, teamID *uint, status fleet.OSSettingsStatus, expectedIDs []uint) { - gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}) + gotHosts, err := ds.ListHosts( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}, + ) require.NoError(t, err) if len(expectedIDs) != len(gotHosts) { gotIDs := make([]uint, len(gotHosts)) for _, h := range gotHosts { gotIDs = append(gotIDs, h.ID) } - require.Len(t, gotHosts, len(expectedIDs), fmt.Sprintf("status: %s expected: %v got: %v", status, expectedIDs, gotIDs)) + require.Len( + t, + gotHosts, + len(expectedIDs), + fmt.Sprintf("status: %s expected: %v got: %v", status, expectedIDs, gotIDs), + ) } for _, h := range gotHosts { require.Contains(t, expectedIDs, h.ID) } - count, err := ds.CountHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}) + count, err := ds.CountHosts( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + fleet.HostListOptions{TeamFilter: teamID, OSSettingsFilter: status}, + ) require.NoError(t, err) require.Equal(t, len(expectedIDs), count, "status: %s", status) } @@ -6047,10 +6343,30 @@ func testMDMProfilesSummaryAndHostFilters(t *testing.T, ds *Datastore) { Verified: expectSummaryWindows[fleet.MDMDeliveryVerified], }) - checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerified, ep[fleet.MDMDeliveryVerified]) - checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsVerifying, ep[fleet.MDMDeliveryVerifying]) - checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsFailed, ep[fleet.MDMDeliveryFailed]) - checkListHostsFilterOSSettings(t, teamID, fleet.OSSettingsPending, ep[fleet.MDMDeliveryPending]) + checkListHostsFilterOSSettings( + t, + teamID, + fleet.OSSettingsVerified, + ep[fleet.MDMDeliveryVerified], + ) + checkListHostsFilterOSSettings( + t, + teamID, + fleet.OSSettingsVerifying, + ep[fleet.MDMDeliveryVerifying], + ) + checkListHostsFilterOSSettings( + t, + teamID, + fleet.OSSettingsFailed, + ep[fleet.MDMDeliveryFailed], + ) + checkListHostsFilterOSSettings( + t, + teamID, + fleet.OSSettingsPending, + ep[fleet.MDMDeliveryPending], + ) } // checkWinHostProfiles := func(t *testing.T, hostUUID string, statusByProfUUID map[string]string) { @@ -6093,13 +6409,21 @@ func testMDMProfilesSummaryAndHostFilters(t *testing.T, ds *Datastore) { default: require.FailNow(t, "unknown profile type") } - stmt := fmt.Sprintf(`INSERT INTO %s (host_uuid, %s_uuid, status) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`, table, profType) + stmt := fmt.Sprintf( + `INSERT INTO %s (host_uuid, %s_uuid, status) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE status = ?`, + table, + profType, + ) _, err := q.ExecContext(ctx, stmt, hostUUID, profUUID, status, status) if err != nil { require.NoError(t, err) return err } - stmt = fmt.Sprintf(`UPDATE %s SET operation_type = ? WHERE host_uuid = ? AND %s_uuid = ?`, table, profType) + stmt = fmt.Sprintf( + `UPDATE %s SET operation_type = ? WHERE host_uuid = ? AND %s_uuid = ?`, + table, + profType, + ) _, err = q.ExecContext(ctx, stmt, fleet.MDMOperationTypeInstall, hostUUID, profUUID) require.NoError(t, err) return err @@ -6166,7 +6490,19 @@ func testMDMProfilesSummaryAndHostFilters(t *testing.T, ds *Datastore) { winHostsByID[h.ID] = h } - require.NoError(t, ds.SetOrUpdateMDMData(ctx, h.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet, "")) + require.NoError( + t, + ds.SetOrUpdateMDMData( + ctx, + h.ID, + false, + true, + "https://example.com", + false, + fleet.WellKnownMDMFleet, + "", + ), + ) } checkExpected(t, nil, nil) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 71de52073a..04f5742e61 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -161,15 +161,6 @@ type EnrolledAPIResult struct { // EnrolledAPIResults is a map of enrollments to a per-enrollment API result. type EnrolledAPIResults map[string]*EnrolledAPIResult -// MDMAppleHostDetails represents the device identifiers used to ingest an MDM device as a Fleet -// host pending enrollment. -// See also https://developer.apple.com/documentation/devicemanagement/authenticaterequest. -type MDMAppleHostDetails struct { - SerialNumber string - UDID string - Model string -} - type MDMAppleCommandTimeoutError struct{} func (e MDMAppleCommandTimeoutError) Error() string { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index f8cc5050ca..ec895b482a 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -568,7 +568,10 @@ type Datastore interface { // upgraded from a prior version). CleanupHostOperatingSystems(ctx context.Context) error - UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error + // MDMTurnOff updates Fleet host information related to MDM when a + // host turns off MDM. Anything related to the protocol itself is + // managed separately. + MDMTurnOff(ctx context.Context, uuid string) error /////////////////////////////////////////////////////////////////////////////// // ActivitiesStore @@ -1040,16 +1043,16 @@ type Datastore interface { // joined (nil for no team), and an error. IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devices []godep.Device) (int64, *uint, error) - // IngestMDMAppleDeviceFromCheckin creates a new Fleet host record for an MDM-enrolled device that is - // not already enrolled in Fleet. - IngestMDMAppleDeviceFromCheckin(ctx context.Context, mdmHost MDMAppleHostDetails) error + // MDMAppleUpsertHost creates or matches a Fleet host record for an + // MDM-enrolled device. + MDMAppleUpsertHost(ctx context.Context, mdmHost *Host) error // RestoreMDMApplePendingDEPHost restores a host that was previously deleted from Fleet. RestoreMDMApplePendingDEPHost(ctx context.Context, host *Host) error - // ResetMDMAppleEnrollment resets all tables with enrollment-related + // MDMResetEnrollment resets all tables with enrollment-related // information if a matching row for the host exists. - ResetMDMAppleEnrollment(ctx context.Context, hostUUID string) error + MDMResetEnrollment(ctx context.Context, hostUUID string) error // ListMDMAppleDEPSerialsInTeam returns a list of serial numbers of hosts // that are enrolled or pending enrollment in Fleet's MDM via DEP for the diff --git a/server/mdm/lifecycle/lifecycle.go b/server/mdm/lifecycle/lifecycle.go new file mode 100644 index 0000000000..2fd7ece7f4 --- /dev/null +++ b/server/mdm/lifecycle/lifecycle.go @@ -0,0 +1,272 @@ +package mdmlifecycle + +import ( + "context" + + "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/worker" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +// HostAction is a supported MDM lifecycle action that can be performed on a +// host. +type HostAction string + +const ( + // HostActionTurnOn performs tasks right after a host turns on MDM. + HostActionTurnOn HostAction = "turn-on" + // HostActionTurnOn performs tasks right after a host turns off MDM. + HostActionTurnOff HostAction = "turn-off" + // HostActionTurnOn perform tasks to reset mdm-related information. + HostActionReset HostAction = "reset" + // HostActionDelete perform tasks to cleanup MDM information when a + // host is deleted from fleet. + HostActionDelete HostAction = "delete" +) + +// HostOptions are the options that can be provided for an action. +// +// Not all options are required for all actions, each individual action should +// validate that it receives the required information. +type HostOptions struct { + Action HostAction + Platform string + UUID string + HardwareSerial string + HardwareModel string + EnrollReference string + Host *fleet.Host +} + +// HostLifecycle manages MDM host lifecycle actions +type HostLifecycle struct { + ds fleet.Datastore + logger kitlog.Logger +} + +// New creates a new HostLifecycle struct +func New(ds fleet.Datastore, logger kitlog.Logger) *HostLifecycle { + return &HostLifecycle{ + ds: ds, + logger: logger, + } +} + +// Do executes the provided HostAction based on the platform requested +func (t *HostLifecycle) Do(ctx context.Context, opts HostOptions) error { + switch opts.Platform { + case "darwin": + err := t.doDarwin(ctx, opts) + return ctxerr.Wrapf(ctx, err, "running darwin lifecycle action %s", opts.Action) + case "windows": + err := t.doWindows(ctx, opts) + return ctxerr.Wrapf(ctx, err, "running windows lifecycle action %s", opts.Action) + default: + return ctxerr.Errorf(ctx, "unsupported platform %s", opts.Platform) + } +} + +func (t *HostLifecycle) doDarwin(ctx context.Context, opts HostOptions) error { + switch opts.Action { + case HostActionTurnOn: + return t.turnOnDarwin(ctx, opts) + + case HostActionTurnOff: + return t.doWithUUIDValidation(ctx, t.ds.MDMTurnOff, opts) + + case HostActionReset: + return t.resetDarwin(ctx, opts) + + case HostActionDelete: + return t.deleteDarwin(ctx, opts) + + default: + return ctxerr.Errorf(ctx, "unknown action %s", opts.Action) + + } +} + +func (t *HostLifecycle) doWindows(ctx context.Context, opts HostOptions) error { + switch opts.Action { + case HostActionReset, HostActionTurnOn: + return t.doWithUUIDValidation(ctx, t.ds.MDMResetEnrollment, opts) + + case HostActionTurnOff: + return t.doWithUUIDValidation(ctx, t.ds.MDMTurnOff, opts) + + case HostActionDelete: + return nil + + default: + return ctxerr.Errorf(ctx, "unknown action %s", opts.Action) + } +} + +type uuidFn func(ctx context.Context, uuid string) error + +func (t *HostLifecycle) doWithUUIDValidation(ctx context.Context, action uuidFn, opts HostOptions) error { + if opts.UUID == "" { + return ctxerr.New(ctx, "UUID option is required for this action") + } + + return action(ctx, opts.UUID) +} + +func (t *HostLifecycle) resetDarwin(ctx context.Context, opts HostOptions) error { + if opts.UUID == "" || opts.HardwareSerial == "" || opts.HardwareModel == "" { + return ctxerr.New(ctx, "UUID, HardwareSerial and HardwareModel options are required for this action") + } + + host := &fleet.Host{ + UUID: opts.UUID, + HardwareSerial: opts.HardwareSerial, + HardwareModel: opts.HardwareModel, + } + if err := t.ds.MDMAppleUpsertHost(ctx, host); err != nil { + return ctxerr.Wrap(ctx, err, "upserting mdm host") + } + + err := t.ds.MDMResetEnrollment(ctx, opts.UUID) + return ctxerr.Wrap(ctx, err, "reset mdm enrollment") +} + +func (t *HostLifecycle) turnOnDarwin(ctx context.Context, opts HostOptions) error { + if opts.UUID == "" { + return ctxerr.New(ctx, "UUID option is required for this action") + } + + nanoEnroll, err := t.ds.GetNanoMDMEnrollment(ctx, opts.UUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "retrieving nano enrollment info") + } + + if nanoEnroll == nil || + !nanoEnroll.Enabled || + nanoEnroll.Type != "Device" || + nanoEnroll.TokenUpdateTally != 1 { + return nil + } + + info, err := t.ds.GetHostMDMCheckinInfo(ctx, opts.UUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting checkin info") + } + + var tmID *uint + if info.TeamID != 0 { + tmID = &info.TeamID + } + + // TODO: improve this to not enqueue the job if a host that is + // assigned in ABM is manually enrolling for some reason. + if info.DEPAssignedToFleet || info.InstalledFromDEP { + t.logger.Log("info", "queueing post-enroll task for newly enrolled DEP device", "host_uuid", opts.UUID) + err := worker.QueueAppleMDMJob( + ctx, + t.ds, + t.logger, + worker.AppleMDMPostDEPEnrollmentTask, + opts.UUID, + tmID, + opts.EnrollReference, + ) + return ctxerr.Wrap(ctx, err, "queue DEP post-enroll task") + } + + // manual MDM enrollments + if !info.InstalledFromDEP { + if err := worker.QueueAppleMDMJob( + ctx, + t.ds, + t.logger, + worker.AppleMDMPostManualEnrollmentTask, + opts.UUID, + tmID, + opts.EnrollReference, + ); err != nil { + return ctxerr.Wrap(ctx, err, "queue manual post-enroll task") + } + } + + return nil +} + +func (t *HostLifecycle) deleteDarwin(ctx context.Context, opts HostOptions) error { + if opts.Host == nil { + return ctxerr.New(ctx, "a non-nil Host option is required to perform this action") + } + + // NOTE: deletion of mdm-related tables is handled by the ds.DeleteHost method. + + // Try to immediately restore a host if it's assigned to us in ABM + if !license.IsPremium(ctx) { + // only premium tier supports DEP so nothing more to do + return nil + } + + ac, err := t.ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get app config") + } else if !ac.MDM.AppleBMEnabledAndConfigured { + // if ABM is not enabled and configured, nothing more to do + return nil + } + + dep, err := t.ds.GetHostDEPAssignment(ctx, opts.Host.ID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "get host dep assignment") + } + + if dep != nil && dep.DeletedAt == nil { + return t.restorePendingDEPHost(ctx, opts.Host, ac) + } + + // no DEP assignment was found or the DEP assignment was deleted in ABM + // so nothing more to do + return nil +} + +func (t *HostLifecycle) restorePendingDEPHost(ctx context.Context, host *fleet.Host, appCfg *fleet.AppConfig) error { + tmID, err := t.getConfigAppleBMDefaultTeamID(ctx, appCfg) + if err != nil { + return ctxerr.Wrap(ctx, err, "restore pending dep host") + } + host.TeamID = tmID + + if err := t.ds.RestoreMDMApplePendingDEPHost(ctx, host); err != nil { + return ctxerr.Wrap(ctx, err, "restore pending dep host") + } + + if _, err := worker.QueueMacosSetupAssistantJob(ctx, t.ds, t.logger, + worker.MacosSetupAssistantHostsTransferred, tmID, host.HardwareSerial); err != nil { + return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") + } + + 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 + } + } + + return tmID, nil +} diff --git a/server/mdm/lifecycle/lifecycle_test.go b/server/mdm/lifecycle/lifecycle_test.go new file mode 100644 index 0000000000..fd1ba27b38 --- /dev/null +++ b/server/mdm/lifecycle/lifecycle_test.go @@ -0,0 +1,61 @@ +package mdmlifecycle + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/mock" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/require" +) + +func TestDoUnsupportedParams(t *testing.T) { + ds := new(mock.Store) + lc := New(ds, kitlog.NewNopLogger()) + + err := lc.Do(context.Background(), HostOptions{}) + require.ErrorContains(t, err, "unsupported platform") + + err = lc.Do(context.Background(), HostOptions{Platform: "linux"}) + require.ErrorContains(t, err, "unsupported platform") + + err = lc.Do(context.Background(), HostOptions{Platform: "darwin", Action: "invalid"}) + require.ErrorContains(t, err, "unknown action") + + err = lc.Do(context.Background(), HostOptions{Platform: "windows", Action: "invalid"}) + require.ErrorContains(t, err, "unknown action") +} + +func TestDoParamValidation(t *testing.T) { + ds := new(mock.Store) + lf := New(ds, kitlog.NewNopLogger()) + ctx := context.Background() + + cases := []struct { + platform string + action HostAction + wantErr bool + }{ + + {"darwin", HostActionTurnOn, true}, + {"darwin", HostActionTurnOff, true}, + {"darwin", HostActionReset, true}, + {"darwin", HostActionDelete, true}, + {"windows", HostActionTurnOn, true}, + {"windows", HostActionTurnOff, true}, + {"windows", HostActionReset, true}, + {"windows", HostActionDelete, false}, + } + + for _, tc := range cases { + err := lf.Do(ctx, HostOptions{ + Action: tc.action, + Platform: tc.platform, + }) + if tc.wantErr { + require.ErrorContains(t, err, "required") + } else { + require.NoError(t, err) + } + } +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 07260d7d03..7f14f0d000 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -409,7 +409,7 @@ type UpdateHostOperatingSystemFunc func(ctx context.Context, hostID uint, hostOS type CleanupHostOperatingSystemsFunc func(ctx context.Context) error -type UpdateHostTablesOnMDMUnenrollFunc func(ctx context.Context, uuid string) error +type MDMTurnOffFunc func(ctx context.Context, uuid string) error type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error @@ -703,11 +703,11 @@ type UpsertMDMAppleHostDEPAssignmentsFunc func(ctx context.Context, hosts []flee type IngestMDMAppleDevicesFromDEPSyncFunc func(ctx context.Context, devices []godep.Device) (int64, *uint, error) -type IngestMDMAppleDeviceFromCheckinFunc func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error +type MDMAppleUpsertHostFunc func(ctx context.Context, mdmHost *fleet.Host) error type RestoreMDMApplePendingDEPHostFunc func(ctx context.Context, host *fleet.Host) error -type ResetMDMAppleEnrollmentFunc func(ctx context.Context, hostUUID string) error +type MDMResetEnrollmentFunc func(ctx context.Context, hostUUID string) error type ListMDMAppleDEPSerialsInTeamFunc func(ctx context.Context, teamID *uint) ([]string, error) @@ -1505,8 +1505,8 @@ type DataStore struct { CleanupHostOperatingSystemsFunc CleanupHostOperatingSystemsFunc CleanupHostOperatingSystemsFuncInvoked bool - UpdateHostTablesOnMDMUnenrollFunc UpdateHostTablesOnMDMUnenrollFunc - UpdateHostTablesOnMDMUnenrollFuncInvoked bool + MDMTurnOffFunc MDMTurnOffFunc + MDMTurnOffFuncInvoked bool NewActivityFunc NewActivityFunc NewActivityFuncInvoked bool @@ -1946,14 +1946,14 @@ type DataStore struct { IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFunc IngestMDMAppleDevicesFromDEPSyncFuncInvoked bool - IngestMDMAppleDeviceFromCheckinFunc IngestMDMAppleDeviceFromCheckinFunc - IngestMDMAppleDeviceFromCheckinFuncInvoked bool + MDMAppleUpsertHostFunc MDMAppleUpsertHostFunc + MDMAppleUpsertHostFuncInvoked bool RestoreMDMApplePendingDEPHostFunc RestoreMDMApplePendingDEPHostFunc RestoreMDMApplePendingDEPHostFuncInvoked bool - ResetMDMAppleEnrollmentFunc ResetMDMAppleEnrollmentFunc - ResetMDMAppleEnrollmentFuncInvoked bool + MDMResetEnrollmentFunc MDMResetEnrollmentFunc + MDMResetEnrollmentFuncInvoked bool ListMDMAppleDEPSerialsInTeamFunc ListMDMAppleDEPSerialsInTeamFunc ListMDMAppleDEPSerialsInTeamFuncInvoked bool @@ -3638,11 +3638,11 @@ func (s *DataStore) CleanupHostOperatingSystems(ctx context.Context) error { return s.CleanupHostOperatingSystemsFunc(ctx) } -func (s *DataStore) UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error { +func (s *DataStore) MDMTurnOff(ctx context.Context, uuid string) error { s.mu.Lock() - s.UpdateHostTablesOnMDMUnenrollFuncInvoked = true + s.MDMTurnOffFuncInvoked = true s.mu.Unlock() - return s.UpdateHostTablesOnMDMUnenrollFunc(ctx, uuid) + return s.MDMTurnOffFunc(ctx, uuid) } func (s *DataStore) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { @@ -4667,11 +4667,11 @@ func (s *DataStore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, device return s.IngestMDMAppleDevicesFromDEPSyncFunc(ctx, devices) } -func (s *DataStore) IngestMDMAppleDeviceFromCheckin(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { +func (s *DataStore) MDMAppleUpsertHost(ctx context.Context, mdmHost *fleet.Host) error { s.mu.Lock() - s.IngestMDMAppleDeviceFromCheckinFuncInvoked = true + s.MDMAppleUpsertHostFuncInvoked = true s.mu.Unlock() - return s.IngestMDMAppleDeviceFromCheckinFunc(ctx, mdmHost) + return s.MDMAppleUpsertHostFunc(ctx, mdmHost) } func (s *DataStore) RestoreMDMApplePendingDEPHost(ctx context.Context, host *fleet.Host) error { @@ -4681,11 +4681,11 @@ func (s *DataStore) RestoreMDMApplePendingDEPHost(ctx context.Context, host *fle return s.RestoreMDMApplePendingDEPHostFunc(ctx, host) } -func (s *DataStore) ResetMDMAppleEnrollment(ctx context.Context, hostUUID string) error { +func (s *DataStore) MDMResetEnrollment(ctx context.Context, hostUUID string) error { s.mu.Lock() - s.ResetMDMAppleEnrollmentFuncInvoked = true + s.MDMResetEnrollmentFuncInvoked = true s.mu.Unlock() - return s.ResetMDMAppleEnrollmentFunc(ctx, hostUUID) + return s.MDMResetEnrollmentFunc(ctx, hostUUID) } func (s *DataStore) ListMDMAppleDEPSerialsInTeam(ctx context.Context, teamID *uint) ([]string, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index b84d1888db..637c703044 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -23,7 +23,6 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -31,11 +30,11 @@ import ( 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/mobileconfig" + 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/mdm" nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" "github.com/fleetdm/fleet/v4/server/sso" - "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/google/uuid" @@ -1441,6 +1440,16 @@ func (svc *Service) pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx context } // 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: "darwin", + UUID: deviceID, + }) + if err != nil { + return err + } return nil } } @@ -2447,13 +2456,20 @@ func (svc *Service) MDMAppleDisableFileVaultAndEscrow(ctx context.Context, teamI //////////////////////////////////////////////////////////////////////////////// type MDMAppleCheckinAndCommandService struct { - ds fleet.Datastore - logger kitlog.Logger - commander *apple_mdm.MDMAppleCommander + ds fleet.Datastore + logger kitlog.Logger + commander *apple_mdm.MDMAppleCommander + mdmLifecycle *mdmlifecycle.HostLifecycle } func NewMDMAppleCheckinAndCommandService(ds fleet.Datastore, commander *apple_mdm.MDMAppleCommander, logger kitlog.Logger) *MDMAppleCheckinAndCommandService { - return &MDMAppleCheckinAndCommandService{ds: ds, commander: commander, logger: logger} + mdmLifecycle := mdmlifecycle.New(ds, logger) + return &MDMAppleCheckinAndCommandService{ + ds: ds, + commander: commander, + logger: logger, + mdmLifecycle: mdmLifecycle, + } } // Authenticate handles MDM [Authenticate][1] requests. @@ -2467,36 +2483,38 @@ func NewMDMAppleCheckinAndCommandService(ds fleet.Datastore, commander *apple_md // // [1]: https://developer.apple.com/documentation/devicemanagement/authenticate func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm.Authenticate) error { - host := fleet.MDMAppleHostDetails{} - host.SerialNumber = m.SerialNumber - host.UDID = m.UDID - host.Model = m.Model - - existingDeviceInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) + existingDeviceInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID) if err != nil { var nfe fleet.NotFoundError if !errors.As(err, &nfe) { - return ctxerr.Wrap(r.Context, err, "getting checkin info in Authenticate message") + return ctxerr.Wrap(r.Context, err, "getting checkin info") } } else if existingDeviceInfo.SCEPRenewalInProgress { - svc.logger.Log("info", "Authenticate message received for a SCEP renewal in process, skipping host ingestion and cleanups", "host_uuid", r.ID) + svc.logger.Log("info", "host lifecycle action received for a SCEP renewal in process, skipping host ingestion and cleanups", "host_uuid", r.ID) return nil } - if err := svc.ds.IngestMDMAppleDeviceFromCheckin(r.Context, host); err != nil { - return ctxerr.Wrap(r.Context, err, "ingesting device in Authenticate message") + err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionReset, + Platform: "darwin", + UUID: m.UDID, + HardwareSerial: m.SerialNumber, + HardwareModel: m.Model, + }) + if err != nil { + return err } - if err := svc.ds.ResetMDMAppleEnrollment(r.Context, host.UDID); err != nil { - return ctxerr.Wrap(r.Context, err, "resetting nano enrollment info in Authenticate message") - } - info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) + + // MDM state changes after is reset, fetch the checkin updatedInfo again + updatedInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID) if err != nil { return ctxerr.Wrap(r.Context, err, "getting checkin info in Authenticate message") } + return svc.ds.NewActivity(r.Context, nil, &fleet.ActivityTypeMDMEnrolled{ - HostSerial: info.HardwareSerial, - HostDisplayName: info.DisplayName, - InstalledFromDEP: info.DEPAssignedToFleet, + HostSerial: updatedInfo.HardwareSerial, + HostDisplayName: updatedInfo.DisplayName, + InstalledFromDEP: updatedInfo.DEPAssignedToFleet, MDMPlatform: fleet.MDMPlatformApple, }) } @@ -2507,69 +2525,23 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm // // [1]: https://developer.apple.com/documentation/devicemanagement/token_update func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.TokenUpdate) error { - info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.UDID) + info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID) if err != nil { - return ctxerr.Wrap(r.Context, err, "retrieving host checkin info on TokenUpdate") + return ctxerr.Wrap(r.Context, err, "getting checkin info") } if info.SCEPRenewalInProgress { - svc.logger.Log("info", "TokenUpdate message received for a SCEP renewal in process", "host_uuid", r.ID) + svc.logger.Log("info", "host lifecycle action received for a SCEP renewal in process", "host_uuid", r.ID) err := svc.ds.CleanSCEPRenewRefs(r.Context, r.ID) - return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs on TokenUpdate") + return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs") } - nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID) - if err != nil { - return ctxerr.Wrap(r.Context, err, "retrieving nano enrollment info on TokenUpdate") - } - - if nanoEnroll != nil && nanoEnroll.Enabled && - nanoEnroll.Type == "Device" && nanoEnroll.TokenUpdateTally == 1 { - // device is enrolled for the first time, not a token update - svc.logger.Log("info", "TokenUpdate message received for a new enrollment", "host_uuid", r.ID) - - if err := svc.ds.BulkSetPendingMDMHostProfiles(r.Context, nil, nil, nil, []string{r.ID}); err != nil { - return err - } - - var tmID *uint - if info.TeamID != 0 { - tmID = &info.TeamID - } - - // TODO: improve this to not enqueue the job if a host that is - // assigned in ABM is manually enrolling for some reason. - if info.DEPAssignedToFleet || info.InstalledFromDEP { - svc.logger.Log("info", "queueing post-enroll task for newly enrolled DEP device", "host_uuid", r.ID) - if err := worker.QueueAppleMDMJob( - r.Context, - svc.ds, - svc.logger, - worker.AppleMDMPostDEPEnrollmentTask, - r.ID, - tmID, - r.Params[mobileconfig.FleetEnrollReferenceKey], - ); err != nil { - return ctxerr.Wrap(r.Context, err, "queue DEP post-enroll task") - } - } - - // manual MDM enrollments that are not fleet-enrolled yet - if !info.InstalledFromDEP && !info.OsqueryEnrolled { - if err := worker.QueueAppleMDMJob( - r.Context, - svc.ds, - svc.logger, - worker.AppleMDMPostManualEnrollmentTask, - r.ID, - tmID, - r.Params[mobileconfig.FleetEnrollReferenceKey], - ); err != nil { - return ctxerr.Wrap(r.Context, err, "queue manual post-enroll task") - } - } - } - return nil + return svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionTurnOn, + Platform: "darwin", + UUID: r.ID, + EnrollReference: r.Params[mobileconfig.FleetEnrollReferenceKey], + }) } // CheckOut handles MDM [CheckOut][1] requests. @@ -2585,9 +2557,15 @@ func (svc *MDMAppleCheckinAndCommandService) CheckOut(r *mdm.Request, m *mdm.Che return err } - if err := svc.ds.UpdateHostTablesOnMDMUnenroll(r.Context, m.UDID); err != nil { + err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionTurnOff, + Platform: "darwin", + UUID: r.ID, + }) + if err != nil { return err } + return svc.ds.NewActivity(r.Context, nil, &fleet.ActivityTypeMDMUnenrolled{ HostSerial: info.HardwareSerial, HostDisplayName: info.DisplayName, @@ -3098,80 +3076,6 @@ func ReconcileAppleProfiles( return nil } -func (svc *Service) maybeRestorePendingDEPHost(ctx context.Context, host *fleet.Host) error { - if host.Platform != "darwin" { - return nil - } - - if !license.IsPremium(ctx) { - // only premium tier supports DEP so nothing more to do - return nil - } - - ac, err := svc.ds.AppConfig(ctx) - if err != nil { - return ctxerr.Wrap(ctx, err, "maybe restore pending DEP host: get app config") - } else if !ac.MDM.AppleBMEnabledAndConfigured { - // if ABM is not enabled and configured, nothing more to do - return nil - } - - dep, err := svc.ds.GetHostDEPAssignment(ctx, host.ID) - switch { - case err != nil && !fleet.IsNotFound(err): - return ctxerr.Wrap(ctx, err, "maybe restore pending DEP host: get host dep assignment") - case dep != nil && dep.DeletedAt == nil: - return svc.restorePendingDEPHost(ctx, host, ac) - default: - // no DEP assignment was found or the DEP assignment was deleted in ABM - // so nothing more to do - } - - return nil -} - -func (svc *Service) restorePendingDEPHost(ctx context.Context, host *fleet.Host, appCfg *fleet.AppConfig) error { - tmID, err := svc.getConfigAppleBMDefaultTeamID(ctx, appCfg) - if err != nil { - return ctxerr.Wrap(ctx, err, "restore pending dep host") - } - host.TeamID = tmID - - if err := svc.ds.RestoreMDMApplePendingDEPHost(ctx, host); err != nil { - return ctxerr.Wrap(ctx, err, "restore pending dep host") - } - - if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, - worker.MacosSetupAssistantHostsTransferred, tmID, host.HardwareSerial); err != nil { - return ctxerr.Wrap(ctx, err, "restore pending dep host") - } - - return nil -} - -func (svc *Service) getConfigAppleBMDefaultTeamID(ctx context.Context, appCfg *fleet.AppConfig) (*uint, error) { - var tmID *uint - if name := appCfg.MDM.AppleBMDefaultTeam; name != "" { - team, err := svc.ds.TeamByName(ctx, name) - switch { - case fleet.IsNotFound(err): - level.Debug(svc.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 - } - } - - return tmID, nil -} - // scepCertRenewalThresholdDays defines the number of days before a SCEP // certificate must be renewed. const scepCertRenewalThresholdDays = 30 diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index db4650a1d5..8be8d3dc9c 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -32,6 +32,7 @@ import ( fleetmdm "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/mobileconfig" + mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" @@ -898,6 +899,9 @@ func TestMDMCommandAuthz(t *testing.T) { ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { return nil } + ds.MDMTurnOffFunc = func(ctx context.Context, uuid string) error { + return nil + } var mdmEnabled atomic.Bool ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) { @@ -1009,14 +1013,18 @@ func TestMDMCommandAuthz(t *testing.T) { func TestMDMAuthenticateManualEnrollment(t *testing.T) { ds := new(mock.Store) - svc := MDMAppleCheckinAndCommandService{ds: ds} + mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger()) + svc := MDMAppleCheckinAndCommandService{ + ds: ds, + mdmLifecycle: mdmLifecycle, + } ctx := context.Background() uuid, serial, model := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1" - ds.IngestMDMAppleDeviceFromCheckinFunc = func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { - require.Equal(t, uuid, mdmHost.UDID) - require.Equal(t, serial, mdmHost.SerialNumber) - require.Equal(t, model, mdmHost.Model) + ds.MDMAppleUpsertHostFunc = func(ctx context.Context, mdmHost *fleet.Host) error { + require.Equal(t, uuid, mdmHost.UUID) + require.Equal(t, serial, mdmHost.HardwareSerial) + require.Equal(t, model, mdmHost.HardwareModel) return nil } @@ -1041,13 +1049,13 @@ func TestMDMAuthenticateManualEnrollment(t *testing.T) { return nil } - ds.ResetMDMAppleEnrollmentFunc = func(ctx context.Context, hostUUID string) error { + ds.MDMResetEnrollmentFunc = func(ctx context.Context, hostUUID string) error { require.Equal(t, uuid, hostUUID) return nil } err := svc.Authenticate( - &mdm.Request{Context: ctx}, + &mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: uuid}}, &mdm.Authenticate{ Enrollment: mdm.Enrollment{ UDID: uuid, @@ -1057,22 +1065,26 @@ func TestMDMAuthenticateManualEnrollment(t *testing.T) { }, ) require.NoError(t, err) - require.True(t, ds.IngestMDMAppleDeviceFromCheckinFuncInvoked) + require.True(t, ds.MDMAppleUpsertHostFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.True(t, ds.NewActivityFuncInvoked) - require.True(t, ds.ResetMDMAppleEnrollmentFuncInvoked) + require.True(t, ds.MDMResetEnrollmentFuncInvoked) } func TestMDMAuthenticateADE(t *testing.T) { ds := new(mock.Store) - svc := MDMAppleCheckinAndCommandService{ds: ds} + mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger()) + svc := MDMAppleCheckinAndCommandService{ + ds: ds, + mdmLifecycle: mdmLifecycle, + } ctx := context.Background() uuid, serial, model := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1" - ds.IngestMDMAppleDeviceFromCheckinFunc = func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { - require.Equal(t, uuid, mdmHost.UDID) - require.Equal(t, serial, mdmHost.SerialNumber) - require.Equal(t, model, mdmHost.Model) + ds.MDMAppleUpsertHostFunc = func(ctx context.Context, mdmHost *fleet.Host) error { + require.Equal(t, uuid, mdmHost.UUID) + require.Equal(t, serial, mdmHost.HardwareSerial) + require.Equal(t, model, mdmHost.HardwareModel) return nil } @@ -1097,13 +1109,13 @@ func TestMDMAuthenticateADE(t *testing.T) { return nil } - ds.ResetMDMAppleEnrollmentFunc = func(ctx context.Context, hostUUID string) error { + ds.MDMResetEnrollmentFunc = func(ctx context.Context, hostUUID string) error { require.Equal(t, uuid, hostUUID) return nil } err := svc.Authenticate( - &mdm.Request{Context: ctx}, + &mdm.Request{Context: ctx, EnrollID: &mdm.EnrollID{ID: uuid}}, &mdm.Authenticate{ Enrollment: mdm.Enrollment{ UDID: uuid, @@ -1113,15 +1125,20 @@ func TestMDMAuthenticateADE(t *testing.T) { }, ) require.NoError(t, err) - require.True(t, ds.IngestMDMAppleDeviceFromCheckinFuncInvoked) + require.True(t, ds.MDMAppleUpsertHostFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.True(t, ds.NewActivityFuncInvoked) - require.True(t, ds.ResetMDMAppleEnrollmentFuncInvoked) + require.True(t, ds.MDMResetEnrollmentFuncInvoked) } func TestMDMAuthenticateSCEPRenewal(t *testing.T) { ds := new(mock.Store) - svc := MDMAppleCheckinAndCommandService{ds: ds, logger: kitlog.NewNopLogger()} + mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger()) + svc := MDMAppleCheckinAndCommandService{ + ds: ds, + mdmLifecycle: mdmLifecycle, + logger: kitlog.NewNopLogger(), + } ctx := context.Background() uuid, serial, model := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1" @@ -1137,10 +1154,10 @@ func TestMDMAuthenticateSCEPRenewal(t *testing.T) { ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return nil } - ds.ResetMDMAppleEnrollmentFunc = func(ctx context.Context, hostUUID string) error { + ds.MDMResetEnrollmentFunc = func(ctx context.Context, hostUUID string) error { return nil } - ds.IngestMDMAppleDeviceFromCheckinFunc = func(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error { + ds.MDMAppleUpsertHostFunc = func(ctx context.Context, mdmHost *fleet.Host) error { return nil } @@ -1155,10 +1172,10 @@ func TestMDMAuthenticateSCEPRenewal(t *testing.T) { }, ) require.NoError(t, err) - require.False(t, ds.IngestMDMAppleDeviceFromCheckinFuncInvoked) + require.False(t, ds.MDMAppleUpsertHostFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.False(t, ds.NewActivityFuncInvoked) - require.False(t, ds.ResetMDMAppleEnrollmentFuncInvoked) + require.False(t, ds.MDMResetEnrollmentFuncInvoked) } func TestMDMTokenUpdate(t *testing.T) { @@ -1173,7 +1190,13 @@ func TestMDMTokenUpdate(t *testing.T) { NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)), ) cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher, config.MDMConfig{}) - svc := MDMAppleCheckinAndCommandService{ds: ds, commander: cmdr, logger: kitlog.NewNopLogger()} + mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger()) + svc := MDMAppleCheckinAndCommandService{ + ds: ds, + mdmLifecycle: mdmLifecycle, + commander: cmdr, + logger: kitlog.NewNopLogger(), + } uuid, serial, model, wantTeamID := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1", uint(12) ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) { @@ -1191,10 +1214,6 @@ func TestMDMTokenUpdate(t *testing.T) { }, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil - } - ds.NewJobFunc = func(ctx context.Context, j *fleet.Job) (*fleet.Job, error) { return j, nil } @@ -1208,10 +1227,8 @@ func TestMDMTokenUpdate(t *testing.T) { }, ) require.NoError(t, err) - require.True(t, ds.BulkSetPendingMDMHostProfilesFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.True(t, ds.NewJobFuncInvoked) - ds.BulkSetPendingMDMHostProfilesFuncInvoked = false ds.GetHostMDMCheckinInfoFuncInvoked = false ds.NewJobFuncInvoked = false @@ -1229,18 +1246,22 @@ func TestMDMTokenUpdate(t *testing.T) { }, ) require.NoError(t, err) - require.True(t, ds.BulkSetPendingMDMHostProfilesFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.True(t, ds.NewJobFuncInvoked) } func TestMDMCheckout(t *testing.T) { ds := new(mock.Store) - svc := MDMAppleCheckinAndCommandService{ds: ds} + mdmLifecycle := mdmlifecycle.New(ds, kitlog.NewNopLogger()) + svc := MDMAppleCheckinAndCommandService{ + ds: ds, + mdmLifecycle: mdmLifecycle, + logger: kitlog.NewNopLogger(), + } ctx := context.Background() uuid, serial, installedFromDEP, displayName := "ABC-DEF-GHI", "XYZABC", true, "Test's MacBook" - ds.UpdateHostTablesOnMDMUnenrollFunc = func(ctx context.Context, hostUUID string) error { + ds.MDMTurnOffFunc = func(ctx context.Context, hostUUID string) error { require.Equal(t, uuid, hostUUID) return nil } @@ -1266,7 +1287,10 @@ func TestMDMCheckout(t *testing.T) { } err := svc.CheckOut( - &mdm.Request{Context: ctx}, + &mdm.Request{ + Context: ctx, + EnrollID: &mdm.EnrollID{ID: uuid}, + }, &mdm.CheckOut{ Enrollment: mdm.Enrollment{ UDID: uuid, @@ -1274,7 +1298,7 @@ func TestMDMCheckout(t *testing.T) { }, ) require.NoError(t, err) - require.True(t, ds.UpdateHostTablesOnMDMUnenrollFuncInvoked) + require.True(t, ds.MDMTurnOffFuncInvoked) require.True(t, ds.GetHostMDMCheckinInfoFuncInvoked) require.True(t, ds.NewActivityFuncInvoked) } diff --git a/server/service/handler.go b/server/service/handler.go index ddcb585839..dddc850aa3 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -28,9 +28,9 @@ import ( "github.com/fleetdm/fleet/v4/server/service/middleware/mdmconfigured" "github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit" "github.com/go-kit/kit/endpoint" - kitlog "github.com/go-kit/kit/log" - "github.com/go-kit/kit/log/level" kithttp "github.com/go-kit/kit/transport/http" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" diff --git a/server/service/hosts.go b/server/service/hosts.go index 0a460e9fb9..e08bf96ad4 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -23,9 +23,10 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/worker" - "github.com/go-kit/kit/log/level" + "github.com/go-kit/log/level" "github.com/gocarina/gocsv" ) @@ -284,19 +285,44 @@ func (svc *Service) DeleteHosts(ctx context.Context, ids []uint, filter *map[str return &fleet.BadRequestError{Message: "Cannot specify a list of ids and filters at the same time"} } + doDelete := func(hostIDs []uint, hosts []*fleet.Host) error { + if err := svc.ds.DeleteHosts(ctx, hostIDs); err != nil { + return err + } + + mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) + for _, host := range hosts { + if host.Platform == "darwin" || host.Platform == "windows" { + err := mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionDelete, + Host: host, + Platform: host.Platform, + }) + return err + } + } + + return nil + } + if len(ids) > 0 { - err := svc.checkWriteForHostIDs(ctx, ids) + if err := svc.checkWriteForHostIDs(ctx, ids); err != nil { + return err + } + + hosts, err := svc.ds.ListHostsLiteByIDs(ctx, ids) if err != nil { return err } - return svc.ds.DeleteHosts(ctx, ids) + + return doDelete(ids, hosts) } if opts == nil { opts = &fleet.HostListOptions{} } opts.DisableFailingPolicies = true // don't check policies for hosts that are about to be deleted - hostIDs, _, err := svc.hostIDsAndNamesFromFilters(ctx, *opts, lid) + hostIDs, _, hosts, err := svc.hostIDsAndNamesFromFilters(ctx, *opts, lid) if err != nil { return err } @@ -309,7 +335,8 @@ func (svc *Service) DeleteHosts(ctx context.Context, ids []uint, filter *map[str if err != nil { return err } - return svc.ds.DeleteHosts(ctx, hostIDs) + + return doDelete(hostIDs, hosts) } ///////////////////////////////////////////////////////////////////////////////// @@ -719,8 +746,15 @@ func (svc *Service) DeleteHost(ctx context.Context, id uint) error { return ctxerr.Wrap(ctx, err, "delete host") } - if host.Platform == "darwin" { - return svc.maybeRestorePendingDEPHost(ctx, host) + if host.Platform == "windows" || host.Platform == "darwin" { + mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) + err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionDelete, + Platform: host.Platform, + UUID: host.UUID, + Host: host, + }) + return ctxerr.Wrap(ctx, err, "performing MDM actions after delete") } return nil @@ -888,7 +922,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, fi return &fleet.BadRequestError{Message: "filters must be specified"} } - hostIDs, hostNames, err := svc.hostIDsAndNamesFromFilters(ctx, *opt, lid) + hostIDs, hostNames, _, err := svc.hostIDsAndNamesFromFilters(ctx, *opt, lid) if err != nil { return err } @@ -1241,10 +1275,10 @@ func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, return result, lastFetched, nil } -func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, error) { +func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) { filter, err := processHostFilters(ctx, opt, lid) if err != nil { - return nil, nil, err + return nil, nil, nil, err } // Load hosts, either from label if provided or from all hosts. @@ -1256,11 +1290,11 @@ func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.Ho hosts, err = svc.ds.ListHosts(ctx, filter, opt) } if err != nil { - return nil, nil, err + return nil, nil, nil, err } if len(hosts) == 0 { - return nil, nil, nil + return nil, nil, nil, nil } hostIDs := make([]uint, 0, len(hosts)) @@ -1269,7 +1303,7 @@ func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.Ho hostIDs = append(hostIDs, h.ID) hostNames = append(hostNames, h.DisplayName()) } - return hostIDs, hostNames, nil + return hostIDs, hostNames, hosts, nil } func processHostFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) (fleet.TeamFilter, error) { diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index b87b228ab5..35576136ed 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8948,6 +8948,20 @@ type validationErrResp struct { } `json:"errors"` } +func setOrbitEnrollment(t *testing.T, h *fleet.Host, ds fleet.Datastore) string { + orbitKey := uuid.New().String() + _, err := ds.EnrollOrbit(context.Background(), false, fleet.OrbitHostInfo{ + HardwareUUID: *h.OsqueryHostID, + HardwareSerial: h.HardwareSerial, + }, orbitKey, nil) + require.NoError(t, err) + err = ds.SetOrUpdateHostOrbitInfo( + context.Background(), h.ID, "1.22.0", sql.NullString{String: "42", Valid: true}, sql.NullBool{Bool: true, Valid: true}, + ) + require.NoError(t, err) + return orbitKey +} + func createOrbitEnrolledHost(t *testing.T, os, suffix string, ds fleet.Datastore) *fleet.Host { name := t.Name() + suffix h, err := ds.NewHost(context.Background(), &fleet.Host{ @@ -8963,16 +8977,8 @@ func createOrbitEnrolledHost(t *testing.T, os, suffix string, ds fleet.Datastore Platform: os, }) require.NoError(t, err) - orbitKey := uuid.New().String() - _, err = ds.EnrollOrbit(context.Background(), false, fleet.OrbitHostInfo{ - HardwareUUID: *h.OsqueryHostID, - HardwareSerial: h.HardwareSerial, - }, orbitKey, nil) - require.NoError(t, err) - err = ds.SetOrUpdateHostOrbitInfo( - context.Background(), h.ID, "1.22.0", sql.NullString{String: "42", Valid: true}, sql.NullBool{Bool: true, Valid: true}, - ) - require.NoError(t, err) + + orbitKey := setOrbitEnrollment(t, h, ds) h.OrbitNodeKey = &orbitKey return h } diff --git a/server/service/integration_mdm_ddm_test.go b/server/service/integration_mdm_ddm_test.go index 869277f246..4034e5540a 100644 --- a/server/service/integration_mdm_ddm_test.go +++ b/server/service/integration_mdm_ddm_test.go @@ -971,6 +971,9 @@ func (s *integrationMDMTestSuite) TestDDMTransactionRecording() { // a second device requests tokens _, mdmDeviceTwo := createHostThenEnrollMDM(s.ds, s.server.URL, t) + err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger) + require.NoError(t, err) + _, err = mdmDeviceTwo.DeclarativeManagement("tokens") require.NoError(t, err) verifyTransactionRecord(record{ diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go new file mode 100644 index 0000000000..bf660ec620 --- /dev/null +++ b/server/service/integration_mdm_lifecycle_test.go @@ -0,0 +1,762 @@ +package service + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" + "github.com/fleetdm/fleet/v4/server/config" + "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" + "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "github.com/fleetdm/fleet/v4/server/ptr" + kitlog "github.com/go-kit/log" + "github.com/google/uuid" + "github.com/groob/plist" + "github.com/jmoiron/sqlx" + micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/stretchr/testify/require" + "go.mozilla.org/pkcs7" +) + +// NOTE: the mantra for lifecycle events is: +// - Noah: When MDM is turned on, install fleetd, bootstrap package (if DEP), +// and profiles. Don't clear host vitals (everything you see on the Host +// details page) +// - Noah: On re-enrollment, don't clear host vitals. +// - Noah: On lock and wipe, don't clear host vitals. +// - Noah: On delete, clear host vitals. + +// NOTE: ADE lifecycle events are part of the integration_mdm_dep_test.go file + +type mdmLifecycleAssertion[T any] func(t *testing.T, host *fleet.Host, device T) + +func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { + t := s.T() + s.setupLifecycleSettings() + + testCases := []struct { + Name string + Action mdmLifecycleAssertion[*mdmtest.TestAppleMDMClient] + }{ + { + "wiped host turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + s.Do( + "POST", + fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), + nil, + http.StatusNoContent, + ) + + cmd, err := device.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = device.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + require.NoError(t, device.Enroll()) + }, + }, + { + "locked host turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + s.Do( + "POST", + fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), + nil, + http.StatusNoContent, + ) + + cmd, err := device.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = device.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + require.NoError(t, device.Enroll()) + }, + }, + { + "host turns on MDM features out of the blue", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + require.NoError(t, device.Enroll()) + }, + }, + { + "IT admin turns off MDM for a host via the UI then host turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + originalPushMock := s.pushProvider.PushFunc + defer func() { s.pushProvider.PushFunc = originalPushMock }() + + s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + res, err := mockSuccessfulPush(pushes) + require.NoError(t, err) + err = device.Checkout() + require.NoError(t, err) + return res, err + } + + s.Do( + "DELETE", + fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID), + nil, + http.StatusOK, + ) + + require.NoError(t, device.Enroll()) + }, + }, + { + "host is deleted then turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + var delResp deleteHostResponse + s.DoJSON( + "DELETE", + fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), + nil, + http.StatusOK, + &delResp, + ) + + dupeClient := mdmtest.NewTestMDMClientAppleDirect( + mdmtest.AppleEnrollInfo{ + + SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge, + SCEPURL: s.server.URL + apple_mdm.SCEPPath, + MDMURL: s.server.URL + apple_mdm.MDMPath, + }, + ) + dupeClient.UUID = device.UUID + dupeClient.SerialNumber = device.SerialNumber + dupeClient.Model = device.Model + require.NoError(t, dupeClient.Enroll()) + + *device = *dupeClient + }, + }, + { + "host is deleted in bulk then turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + req := deleteHostsRequest{ + IDs: []uint{host.ID}, + } + resp := deleteHostsResponse{} + s.DoJSON("POST", "/api/latest/fleet/hosts/delete", req, http.StatusOK, &resp) + + dupeClient := mdmtest.NewTestMDMClientAppleDirect( + mdmtest.AppleEnrollInfo{ + + SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge, + SCEPURL: s.server.URL + apple_mdm.SCEPPath, + MDMURL: s.server.URL + apple_mdm.MDMPath, + }, + ) + dupeClient.UUID = device.UUID + dupeClient.SerialNumber = device.SerialNumber + dupeClient.Model = device.Model + require.NoError(t, dupeClient.Enroll()) + + *device = *dupeClient + }, + }, + { + "host is deleted then osquery enrolls then turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) { + var delResp deleteHostResponse + s.DoJSON( + "DELETE", + fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), + nil, + http.StatusOK, + &delResp, + ) + + var err error + host.OsqueryHostID = ptr.String(t.Name()) + host, err = s.ds.NewHost(context.Background(), host) + require.NoError(t, err) + + setOrbitEnrollment(t, host, s.ds) + deviceToken := uuid.NewString() + err = s.ds.SetOrUpdateDeviceAuthToken(context.Background(), host.ID, deviceToken) + require.NoError(t, err) + + device.SetDesktopToken(deviceToken) + require.NoError(t, device.Enroll()) + }, + }, + } + + assertAction := func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient, action mdmLifecycleAssertion[*mdmtest.TestAppleMDMClient]) { + fCmds, fSumm, fHostMDM := s.recordAppleHostStatus(host, device) + + action(t, host, device) + + // reload the host by identifier, tests might + // delete hosts and create new records with different IDs + var err error + host, err = s.ds.HostByIdentifier(context.Background(), host.UUID) + require.NoError(t, err) + + sCmds, sSumm, sHostMDM := s.recordAppleHostStatus(host, device) + + // post asssertions + require.ElementsMatch(t, fCmds, sCmds) + require.Equal(t, fSumm, sSumm) + require.Equal(t, fHostMDM, sHostMDM) + } + + for _, tt := range testCases { + t.Run(tt.Name, func(t *testing.T) { + t.Run("manual enrollment", func(t *testing.T) { + host, device := createHostThenEnrollMDM(s.ds, s.server.URL, t) + assertAction(t, host, device, tt.Action) + }) + + 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) { + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"}) + require.NoError(t, err) + case "/profile/devices": + err := encoder.Encode(godep.ProfileResponse{ + ProfileUUID: "abc", + Devices: map[string]string{}, + }) + require.NoError(t, err) + case "/server/devices", "/devices/sync": + err := encoder.Encode(godep.DeviceResponse{ + Devices: []godep.Device{ + { + SerialNumber: device.SerialNumber, + Model: device.Model, + OS: "osx", + OpType: "added", + }, + }, + }) + require.NoError(t, err) + } + })) + + s.runDEPSchedule() + depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) + device.SetDEPToken(depURLToken) + var err error + host, err := s.ds.HostByIdentifier(context.Background(), device.SerialNumber) + require.NoError(t, err) + require.NoError(t, device.Enroll()) + + assertAction(t, host, device, tt.Action) + }) + }) + } +} + +func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() { + t := s.T() + s.setupLifecycleSettings() + + testCases := []struct { + Name string + Action mdmLifecycleAssertion[*mdmtest.TestWindowsMDMClient] + }{ + { + "wiped host turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) { + s.Do( + "POST", + fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), + nil, + http.StatusNoContent, + ) + + status, err := s.ds.GetHostLockWipeStatus(context.Background(), host) + require.NoError(t, err) + + cmds, err := device.StartManagementSession() + require.NoError(t, err) + + // two status + the wipe command we enqueued + require.Len(t, cmds, 3) + wipeCmd := cmds[status.WipeMDMCommand.CommandUUID] + require.NotNil(t, wipeCmd) + require.Equal(t, wipeCmd.Verb, fleet.CmdExec) + require.Len(t, wipeCmd.Cmd.Items, 1) + require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", *wipeCmd.Cmd.Items[0].Target) + + msgID, err := device.GetCurrentMsgID() + require.NoError(t, err) + + device.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: fleet.CmdStatus}, + MsgRef: &msgID, + CmdRef: &status.WipeMDMCommand.CommandUUID, + Cmd: ptr.String("Exec"), + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + cmds, err = device.SendResponse() + require.NoError(t, err) + // the ack of the message should be the only returned command + require.Len(t, cmds, 1) + + // re-enroll + require.NoError(t, device.Enroll()) + }, + }, + { + "locked host turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) { + s.Do( + "POST", + fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), + nil, + http.StatusNoContent, + ) + + status, err := s.ds.GetHostLockWipeStatus(context.Background(), host) + require.NoError(t, err) + + var orbitScriptResp orbitPostScriptResultResponse + s.DoJSON( + "POST", + "/api/fleet/orbit/scripts/result", + json.RawMessage( + fmt.Sprintf( + `{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, + *host.OrbitNodeKey, + status.LockScript.ExecutionID, + ), + ), + http.StatusOK, + &orbitScriptResp, + ) + + require.NoError(t, device.Enroll()) + }, + }, + { + "host turns on MDM features out of the blue", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) { + require.NoError(t, device.Enroll()) + }, + }, + { + "host is deleted then osquery enrolls then turns on MDM", + func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) { + var delResp deleteHostResponse + s.DoJSON( + "DELETE", + fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), + nil, + http.StatusOK, + &delResp, + ) + + var err error + host.OsqueryHostID = ptr.String(t.Name()) + host, err = s.ds.NewHost(context.Background(), host) + require.NoError(t, err) + + orbitKey := setOrbitEnrollment(t, host, s.ds) + host.OrbitNodeKey = &orbitKey + if !strings.Contains(device.TokenIdentifier, "@") { + device.TokenIdentifier = orbitKey + } + device.HardwareID = host.UUID + device.DeviceID = host.UUID + + require.NoError(t, device.Enroll()) + }, + }, + } + + assertAction := func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient, action mdmLifecycleAssertion[*mdmtest.TestWindowsMDMClient]) { + fCmds, fSumm, fHostMDM := s.recordWindowsHostStatus(host, device) + + action(t, host, device) + + // reload the host by identifier, tests might + // delete hosts and create new records with different IDs + var err error + host, err = s.ds.HostByIdentifier(context.Background(), host.UUID) + require.NoError(t, err) + + sCmds, sSumm, sHostMDM := s.recordWindowsHostStatus(host, device) + + // post asssertions + require.Len(t, sCmds, len(fCmds)) + require.ElementsMatch(t, fCmds, sCmds) + require.Equal(t, fSumm, sSumm) + require.Equal(t, fHostMDM, sHostMDM) + } + + for _, tt := range testCases { + t.Run(tt.Name, func(t *testing.T) { + t.Run("programmatic enrollment", func(t *testing.T) { + host, device := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + err := s.ds.SetOrUpdateMDMData(context.Background(), host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "") + require.NoError(t, err) + assertAction(t, host, device, tt.Action) + }) + + t.Run("automatic enrollment", func(t *testing.T) { + if strings.Contains(tt.Name, "wipe") { + t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO") + } + + err := s.ds.ApplyEnrollSecrets(context.Background(), nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) + require.NoError(t, err) + + host := createOrbitEnrolledHost(t, "windows", "windows_automatic", s.ds) + + azureMail := "foo.bar.baz@example.com" + device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail) + device.HardwareID = host.UUID + device.DeviceID = host.UUID + require.NoError(t, device.Enroll()) + + err = s.ds.SetOrUpdateMDMData(context.Background(), host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "") + require.NoError(t, err) + + assertAction(t, host, device, tt.Action) + }) + }) + } +} + +// Hardcode response type because we are using a custom json marshaling so +// using getHostMDMResponse fails with "JSON unmarshaling is not supported for HostMDM". +type jsonMDM struct { + EnrollmentStatus string `json:"enrollment_status"` + ServerURL string `json:"server_url"` + Name string `json:"name,omitempty"` + ID *uint `json:"id,omitempty"` +} +type getHostMDMResponseTest struct { + HostMDM *jsonMDM + Err error `json:"error,omitempty"` +} + +func (s *integrationMDMTestSuite) recordWindowsHostStatus( + host *fleet.Host, + device *mdmtest.TestWindowsMDMClient, +) ([]fleet.ProtoCmdOperation, getHostMDMSummaryResponse, getHostMDMResponseTest) { + t := s.T() + + var recordedCmds []fleet.ProtoCmdOperation + cmds, err := device.StartManagementSession() + require.NoError(t, err) + + msgID, err := device.GetCurrentMsgID() + require.NoError(t, err) + for _, c := range cmds { + cmdID := c.Cmd.CmdID + status := syncml.CmdStatusOK + device.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: fleet.CmdStatus}, + MsgRef: &msgID, + CmdRef: &cmdID.Value, + Cmd: ptr.String(c.Verb), + Data: &status, + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + c.Cmd.CmdID.Value = "" + c.Cmd.CmdRef = nil + recordedCmds = append(recordedCmds, c) + } + + _, err = device.SendResponse() + require.NoError(t, err) + + mdmAgg := getHostMDMSummaryResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/summary/mdm", nil, http.StatusOK, &mdmAgg) + + ghr := getHostMDMResponseTest{} + s.DoJSON( + "GET", + fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID), + nil, + http.StatusOK, + &ghr, + ) + + return recordedCmds, mdmAgg, ghr +} + +func (s *integrationMDMTestSuite) recordAppleHostStatus( + host *fleet.Host, + device *mdmtest.TestAppleMDMClient, +) ([]*micromdm.CommandPayload, getHostMDMSummaryResponse, getHostMDMResponseTest) { + t := s.T() + + s.runWorker() + s.awaitTriggerProfileSchedule(t) + + var cmds []*micromdm.CommandPayload + + cmd, err := device.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + + // command uuid is a random value, we only care that's set + require.NotEmpty(t, fullCmd.CommandUUID) + fullCmd.CommandUUID = "" + + // strip the signature of the profiles so they can be easily compared + if fullCmd.Command.RequestType == "InstallProfile" { + p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload) + require.NoError(t, err) + fullCmd.Command.InstallProfile.Payload = p7.Content + } + cmds = append(cmds, &fullCmd) + + cmd, err = device.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + mdmAgg := getHostMDMSummaryResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/summary/mdm", nil, http.StatusOK, &mdmAgg) + + ghr := getHostMDMResponseTest{} + s.DoJSON( + "GET", + fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID), + nil, + http.StatusOK, + &ghr, + ) + + return cmds, mdmAgg, ghr +} + +func (s *integrationMDMTestSuite) setupLifecycleSettings() { + t := s.T() + ctx := context.Background() + // add bootstrap package + _ = s.ds.DeleteMDMAppleBootstrapPackage(ctx, 0) + bp, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg")) + require.NoError(t, err) + s.uploadBootstrapPackage( + &fleet.MDMAppleBootstrapPackage{Bytes: bp, Name: "pkg.pkg", TeamID: 0}, + http.StatusOK, + "", + ) + + // enable disk encryption + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "macos_settings": {"enable_disk_encryption": true} } + }`), http.StatusOK, &acResp) + require.True(t, acResp.MDM.EnableDiskEncryption.Value) + + // add profiles (windows, mac) + s.Do( + "POST", + "/api/v1/fleet/mdm/profiles/batch", + batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, + {Name: "N2", Contents: syncMLForTest("./Foo/Bar")}, + {Name: "N3", Contents: declarationForTest("D1")}, + }}, + http.StatusNoContent, + ) +} + +// Host is renewing SCEP certificates +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.runDEPSchedule() + + // add a device that's manually enrolled + desktopToken := uuid.New().String() + manualHost := createOrbitEnrolledHost(t, "darwin", "h1", s.ds) + err := s.ds.SetOrUpdateDeviceAuthToken(context.Background(), manualHost.ID, desktopToken) + require.NoError(t, err) + manualEnrolledDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken) + manualEnrolledDevice.UUID = manualHost.UUID + err = manualEnrolledDevice.Enroll() + require.NoError(t, err) + + // add a device that's automatically enrolled + automaticHost := createOrbitEnrolledHost(t, "darwin", "h2", s.ds) + depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) + automaticEnrolledDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) + automaticEnrolledDevice.UUID = automaticHost.UUID + automaticEnrolledDevice.SerialNumber = automaticHost.HardwareSerial + err = automaticEnrolledDevice.Enroll() + require.NoError(t, err) + + // add a device that's automatically enrolled with a server ref + automaticHostWithRef := createOrbitEnrolledHost(t, "darwin", "h3", s.ds) + automaticEnrolledDeviceWithRef := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) + automaticEnrolledDeviceWithRef.UUID = automaticHostWithRef.UUID + automaticEnrolledDeviceWithRef.SerialNumber = automaticHostWithRef.HardwareSerial + err = automaticEnrolledDeviceWithRef.Enroll() + require.NoError( + t, + s.ds.SetOrUpdateMDMData( + ctx, + automaticHostWithRef.ID, + false, + true, + s.server.URL, + true, + fleet.WellKnownMDMFleet, + "foo", + ), + ) + require.NoError(t, err) + + // add global profiles + globalProfiles := [][]byte{ + mobileconfigForTest("N1", "I1"), + mobileconfigForTest("N2", "I2"), + } + s.Do( + "POST", + "/api/v1/fleet/mdm/apple/profiles/batch", + batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, + http.StatusNoContent, + ) + // ack all commands to install profiles + cmd, err := manualEnrolledDevice.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = manualEnrolledDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = automaticEnrolledDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + for cmd != nil { + cmd, err = automaticEnrolledDeviceWithRef.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + cert, key, err := generateCertWithAPNsTopic() + require.NoError(t, err) + fleetCfg := config.TestConfig() + config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, testBMToken, "") + logger := kitlog.NewJSONLogger(os.Stdout) + + // run without expired certs, no command enqueued + err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) + require.NoError(t, err) + cmd, err = manualEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + // expire all the certs we just created + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + UPDATE nano_cert_auth_associations + SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR) + WHERE id IN (?, ?, ?) + `, manualHost.UUID, automaticHost.UUID, automaticHostWithRef.UUID) + return err + }) + + // generate a new config here so we can manipulate the certs. + err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) + require.NoError(t, err) + + checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string) { + var renewCmd *mdm.Command + cmd, err := device.Idle() + require.NoError(t, err) + for cmd != nil { + if cmd.Command.RequestType == "InstallProfile" { + renewCmd = cmd + } + cmd, err = device.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.NotNil(t, renewCmd) + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(renewCmd.Raw, &fullCmd)) + s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef) + } + + checkRenewCertCommand(manualEnrolledDevice, "") + checkRenewCertCommand(automaticEnrolledDevice, "") + checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo") + + // another cron run shouldn't enqueue more commands + err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) + require.NoError(t, err) + + cmd, err = manualEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + // devices renew their SCEP cert by re-enrolling. + require.NoError(t, manualEnrolledDevice.Enroll()) + require.NoError(t, automaticEnrolledDevice.Enroll()) + require.NoError(t, automaticEnrolledDeviceWithRef.Enroll()) + + // no new commands are enqueued right after enrollment + cmd, err = manualEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDevice.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + cmd, err = automaticEnrolledDeviceWithRef.Idle() + require.NoError(t, err) + require.Nil(t, cmd) +} diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 897fcce051..303d147abf 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -1983,14 +1983,20 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { require.Nil(t, h1.TeamID) h2 := createManualMDMEnrollWithOrbit(globalEnrollSec) require.Nil(t, h2.TeamID) + // run the cron + s.awaitTriggerProfileSchedule(t) s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) @@ -2001,14 +2007,20 @@ func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { h4 := createManualMDMEnrollWithOrbit(tm1EnrollSec) require.NotNil(t, h4.TeamID) require.Equal(t, tm1.ID, *h4.TeamID) + // run the cron + s.awaitTriggerProfileSchedule(t) s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index dedd92f3e0..7dc1ee4fce 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -313,6 +313,8 @@ func (s *integrationMDMTestSuite) TearDownTest() { appCfg.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) // ensure global Windows OS updates are always disabled for the next test appCfg.MDM.WindowsUpdates = fleet.WindowsUpdates{} + // ensure the server URL is constant + appCfg.ServerSettings.ServerURL = s.server.URL err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) require.NoError(t, err) @@ -7580,7 +7582,6 @@ func (s *integrationMDMTestSuite) checkMDMProfilesSummaries(t *testing.T, teamID if expectedAppleSummary != nil { var apple getMDMAppleProfilesSummaryResponse s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryRequest{}, http.StatusOK, &apple, queryParams...) - fmt.Println(expectedSummary, apple) require.Equal(t, expectedSummary.Failed, apple.Failed, "failed summary count doesn't match") require.Equal(t, expectedSummary.Pending, apple.Pending, "pending summary count doesn't match") require.Equal(t, expectedSummary.Verifying, apple.Verifying, "verifying summary count doesn't match") @@ -7692,7 +7693,7 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() { checkInstallFleetdCommandSent(mdmDevice, true) // create a device that's enrolled into Fleet before turning on MDM features, - // it shouldn't get the command to install fleetd + // it should still get the command to install fleetd if turns on MDM. desktopToken := uuid.New().String() host := createOrbitEnrolledHost(t, "darwin", "h1", s.ds) err = s.ds.SetOrUpdateDeviceAuthToken(context.Background(), host.ID, desktopToken) @@ -7702,7 +7703,7 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() { err = mdmDevice.Enroll() require.NoError(t, err) s.runWorker() - checkInstallFleetdCommandSent(mdmDevice, false) + checkInstallFleetdCommandSent(mdmDevice, true) } func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() { @@ -8375,161 +8376,6 @@ func (s *integrationMDMTestSuite) TestDontIgnoreAnyProfileErrors() { } } -func (s *integrationMDMTestSuite) TestSCEPCertExpiration() { - 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.runDEPSchedule() - - // add a device that's manually enrolled - desktopToken := uuid.New().String() - manualHost := createOrbitEnrolledHost(t, "darwin", "h1", s.ds) - err := s.ds.SetOrUpdateDeviceAuthToken(context.Background(), manualHost.ID, desktopToken) - require.NoError(t, err) - manualEnrolledDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken) - manualEnrolledDevice.UUID = manualHost.UUID - err = manualEnrolledDevice.Enroll() - require.NoError(t, err) - - // add a device that's automatically enrolled - automaticHost := createOrbitEnrolledHost(t, "darwin", "h2", s.ds) - depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) - automaticEnrolledDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) - automaticEnrolledDevice.UUID = automaticHost.UUID - automaticEnrolledDevice.SerialNumber = automaticHost.HardwareSerial - err = automaticEnrolledDevice.Enroll() - require.NoError(t, err) - - // add a device that's automatically enrolled with a server ref - automaticHostWithRef := createOrbitEnrolledHost(t, "darwin", "h3", s.ds) - automaticEnrolledDeviceWithRef := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) - automaticEnrolledDeviceWithRef.UUID = automaticHostWithRef.UUID - automaticEnrolledDeviceWithRef.SerialNumber = automaticHostWithRef.HardwareSerial - err = automaticEnrolledDeviceWithRef.Enroll() - require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, automaticHostWithRef.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "foo")) - require.NoError(t, err) - - // add global profiles - globalProfiles := [][]byte{ - mobileconfigForTest("N1", "I1"), - mobileconfigForTest("N2", "I2"), - } - s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) - // ack all commands to install profiles - cmd, err := manualEnrolledDevice.Idle() - require.NoError(t, err) - for cmd != nil { - cmd, err = manualEnrolledDevice.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - cmd, err = automaticEnrolledDevice.Idle() - require.NoError(t, err) - for cmd != nil { - cmd, err = automaticEnrolledDevice.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - cmd, err = automaticEnrolledDeviceWithRef.Idle() - require.NoError(t, err) - for cmd != nil { - cmd, err = automaticEnrolledDeviceWithRef.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - - cert, key, err := generateCertWithAPNsTopic() - require.NoError(t, err) - fleetCfg := config.TestConfig() - config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, testBMToken, "") - logger := kitlog.NewJSONLogger(os.Stdout) - - // run without expired certs, no command enqueued - err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) - require.NoError(t, err) - cmd, err = manualEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDeviceWithRef.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - // expire all the certs we just created - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, ` - UPDATE nano_cert_auth_associations - SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR) - WHERE id IN (?, ?, ?) - `, manualHost.UUID, automaticHost.UUID, automaticHostWithRef.UUID) - return err - }) - - // generate a new config here so we can manipulate the certs. - err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) - require.NoError(t, err) - - checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string) { - var renewCmd *mdm.Command - cmd, err := device.Idle() - require.NoError(t, err) - for cmd != nil { - if cmd.Command.RequestType == "InstallProfile" { - renewCmd = cmd - } - cmd, err = device.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - require.NotNil(t, renewCmd) - var fullCmd micromdm.CommandPayload - require.NoError(t, plist.Unmarshal(renewCmd.Raw, &fullCmd)) - s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef) - } - - checkRenewCertCommand(manualEnrolledDevice, "") - checkRenewCertCommand(automaticEnrolledDevice, "") - checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo") - - // another cron run shouldn't enqueue more commands - err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander) - require.NoError(t, err) - - cmd, err = manualEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDeviceWithRef.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - // devices renew their SCEP cert by re-enrolling. - require.NoError(t, manualEnrolledDevice.Enroll()) - require.NoError(t, automaticEnrolledDevice.Enroll()) - require.NoError(t, automaticEnrolledDeviceWithRef.Enroll()) - - // no new commands are enqueued right after enrollment - cmd, err = manualEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDevice.Idle() - require.NoError(t, err) - require.Nil(t, cmd) - - cmd, err = automaticEnrolledDeviceWithRef.Idle() - require.NoError(t, err) - require.Nil(t, cmd) -} - func (s *integrationMDMTestSuite) TestMDMDiskEncryptionIssue16636() { // see https://github.com/fleetdm/fleet/issues/16636 diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 4ceab6d20b..e8a44ae0ae 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -23,6 +23,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" kitlog "github.com/go-kit/kit/log" @@ -1762,6 +1763,20 @@ func (svc *Service) storeWindowsMDMEnrolledDevice(ctx context.Context, userID st return err } + // TODO: azure enrollments come with an empty uuid, I haven't figured + // out a good way to identify the device. + if hostUUID != "" { + mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) + err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionTurnOn, + Platform: "windows", + UUID: hostUUID, + }) + if err != nil { + return err + } + } + err = svc.ds.NewActivity(ctx, nil, &fleet.ActivityTypeMDMEnrolled{ HostDisplayName: reqDeviceName, MDMPlatform: fleet.MDMPlatformMicrosoft, diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 56aeb0df0c..ebef20c09f 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -335,6 +335,7 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ if len(opts) > 0 { mdmStorage := opts[0].MDMStorage scepStorage := opts[0].SCEPStorage + commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher, cfg.MDM) if mdmStorage != nil && scepStorage != nil { err := RegisterAppleMDMProtocolServices( rootMux, @@ -342,11 +343,7 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ mdmStorage, scepStorage, logger, - &MDMAppleCheckinAndCommandService{ - ds: ds, - commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher, cfg.MDM), - logger: kitlog.NewNopLogger(), - }, + NewMDMAppleCheckinAndCommandService(ds, commander, logger), &MDMAppleDDMService{ ds: ds, logger: logger, From a38fa0d5175c39649706d90f706cbea098d58d6a Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Mon, 29 Apr 2024 15:51:16 -0500 Subject: [PATCH 69/83] Change calendar event name (#18579) Co-authored-by: Rachael Shaw --- ee/server/calendar/google_calendar.go | 2 +- tools/calendar/delete-events/delete-events.go | 2 +- tools/calendar/move-events/move-events.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index 7283269dfe..b6df29816c 100644 --- a/ee/server/calendar/google_calendar.go +++ b/ee/server/calendar/google_calendar.go @@ -30,7 +30,7 @@ import ( // to create multiple events in the same calendar. This is useful for load testing. For example: john+test@example.com becomes john@example.com const ( - eventTitle = "💻🚫Downtime" + eventTitle = "💻🚫 Scheduled maintenance" startHour = 9 endHour = 17 eventLength = 30 * time.Minute diff --git a/tools/calendar/delete-events/delete-events.go b/tools/calendar/delete-events/delete-events.go index 23933e11bf..dea8197dfa 100644 --- a/tools/calendar/delete-events/delete-events.go +++ b/tools/calendar/delete-events/delete-events.go @@ -27,7 +27,7 @@ var ( ) const ( - eventTitle = "💻🚫Downtime" + eventTitle = "💻🚫 Scheduled maintenance" ) func main() { diff --git a/tools/calendar/move-events/move-events.go b/tools/calendar/move-events/move-events.go index d4906eaef1..7e413abaf7 100644 --- a/tools/calendar/move-events/move-events.go +++ b/tools/calendar/move-events/move-events.go @@ -28,7 +28,7 @@ var ( ) const ( - eventTitle = "💻🚫Downtime" + eventTitle = "💻🚫 Scheduled maintenance" ) func main() { From 3f2d906717beaa7c6b9e6aeaab83a0305c6c9a91 Mon Sep 17 00:00:00 2001 From: Michael Pinto <147431865+pintomi1989@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:23:40 -0400 Subject: [PATCH 70/83] Update security-audits description (#18611) Adding a description and link for the 2023 penetration test. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [ ] 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 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. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [ ] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [ ] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [ ] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)). --- .../business-operations/security-audits.md | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/handbook/business-operations/security-audits.md b/handbook/business-operations/security-audits.md index b9628c4b2c..3be3f10609 100644 --- a/handbook/business-operations/security-audits.md +++ b/handbook/business-operations/security-audits.md @@ -2,7 +2,30 @@ This page contains explanations of the latest external security audits performed on Fleet software. ## June 2023 penetration testing of Fleet 4.33 -Available on request. +In June 2023, [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. + +You can find the full report here: [2023-06-09-fleet-penetration-test.pdf](https://github.com/fleetdm/fleet/raw/main/docs/files/2023-06-09-fleet-penetration-test.pdf). + +### Findings +#### 1 - Stored cross-site scripting (XSS) in tooltip +| Type | Latacora Severity | +| ------------------- | -------------- | +| Cross-site scripting| High risk | + +All tooltips using the "tipContent" tag are set using "dangerouslySetInnerHTML". This allows manipulation of the DOM without sanitization. If a user can control the content sent to this function, it can lead to a cross-site scripting vulnerability. + +- Resolved. Resolution information TBA + +#### 2 - Broken authorization leads to observers able to add hosts +| Type | Latacora Severity | +| ------------------- | -------------- | +| Authorization issue | High risk | + +Observers are not supposed to be able to add hosts to Fleet. Via specific endpoints, it becomes possible to retrieve the certificate chains and the secrets for all teams, and these are the information required to add a host. + +- Resolved. Resolution information TBA ## April 2022 penetration testing of Fleet 4.12 In April 2022, we worked with [Lares](https://www.lares.com/) to perform penetration testing on our Fleet instance, which was running 4.12 at the time. From bde910a82767e518bd9b1a662089961d3dc62f7b Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 29 Apr 2024 16:29:23 -0500 Subject: [PATCH 71/83] Website: Update get-enriched to return undefined. (#18614) Changes: - Updated the `iq/get-enriched` helper to return enrichment data as undefined. --- website/api/helpers/iq/get-enriched.js | 243 +++++++++++++------------ 1 file changed, 122 insertions(+), 121 deletions(-) diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index bc6411059f..320ce6fda5 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -46,141 +46,142 @@ module.exports = { }, - fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { - require('assert')(sails.config.custom.iqSecret); + fn: async function () { + // fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { + // require('assert')(sails.config.custom.iqSecret); - let RX_TECHNOLOGY_CATEGORIES = /(device|security|endpoint|configuration management|data management platforms|mobility management|identity|information technology|IT$|employee experience|apple)/i; + // let RX_TECHNOLOGY_CATEGORIES = /(device|security|endpoint|configuration management|data management platforms|mobility management|identity|information technology|IT$|employee experience|apple)/i; - // [?] https://developer.leadiq.com/#query-searchPeople - // [?] https://developer.leadiq.com/#definition-SearchPeopleInput - // [?] https://graphql.org/learn/serving-over-http/ - let emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; + // // [?] https://developer.leadiq.com/#query-searchPeople + // // [?] https://developer.leadiq.com/#definition-SearchPeopleInput + // // [?] https://graphql.org/learn/serving-over-http/ + // let emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; - let searchExpr = `{ - ${emailAddress? 'email: '+ JSON.stringify(emailAddress) : ''} - ${linkedinUrl? 'linkedinUrl: '+ JSON.stringify(linkedinUrl) : ''} - ${firstName? 'firstName: '+ JSON.stringify(firstName) : ''} - ${lastName? 'lastName: '+ JSON.stringify(lastName) : ''} - ${organization || emailDomain ? (`company: { - ${organization? 'name: '+ JSON.stringify(organization) : ''} - ${emailDomain? 'domain: '+ JSON.stringify(emailDomain)+' '+'emailDomain: '+ JSON.stringify(emailDomain) : ''} - searchInPastCompanies: false - strict: false - }`) : ''} - }`; //sails.log('GraphQL query:',searchExpr); - let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { - query: `{ searchPeople(input: ${searchExpr}) { - totalResults - results { - _id - name { first last } - linkedin { linkedinId linkedinUrl status updatedAt } - profiles { network id username url status updatedAt } - location { country areaLevel1 city fullAddress type status updatedAt } - personalPhones { value type status verificationStatus } - currentPositions { - title - emails { value type status } - phones { value type status verificationStatus } - companyInfo { - name - domain - country - address - linkedinUrl - numberOfEmployees - technologies { name category parentCategory attributes categories } - } - } - } - } - }`, - }, { - Authorization: `Basic ${sails.config.custom.iqSecret}`, - 'content-type': 'application/json' - }); + // let searchExpr = `{ + // ${emailAddress? 'email: '+ JSON.stringify(emailAddress) : ''} + // ${linkedinUrl? 'linkedinUrl: '+ JSON.stringify(linkedinUrl) : ''} + // ${firstName? 'firstName: '+ JSON.stringify(firstName) : ''} + // ${lastName? 'lastName: '+ JSON.stringify(lastName) : ''} + // ${organization || emailDomain ? (`company: { + // ${organization? 'name: '+ JSON.stringify(organization) : ''} + // ${emailDomain? 'domain: '+ JSON.stringify(emailDomain)+' '+'emailDomain: '+ JSON.stringify(emailDomain) : ''} + // searchInPastCompanies: false + // strict: false + // }`) : ''} + // }`; //sails.log('GraphQL query:',searchExpr); + // let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { + // query: `{ searchPeople(input: ${searchExpr}) { + // totalResults + // results { + // _id + // name { first last } + // linkedin { linkedinId linkedinUrl status updatedAt } + // profiles { network id username url status updatedAt } + // location { country areaLevel1 city fullAddress type status updatedAt } + // personalPhones { value type status verificationStatus } + // currentPositions { + // title + // emails { value type status } + // phones { value type status verificationStatus } + // companyInfo { + // name + // domain + // country + // address + // linkedinUrl + // numberOfEmployees + // technologies { name category parentCategory attributes categories } + // } + // } + // } + // } + // }`, + // }, { + // Authorization: `Basic ${sails.config.custom.iqSecret}`, + // 'content-type': 'application/json' + // }); - if (report.errors) { - sails.log.warn('Errors returned from IQ API when attempting to search for a matching contact:',report.errors); - } + // if (report.errors) { + // sails.log.warn('Errors returned from IQ API when attempting to search for a matching contact:',report.errors); + // } - // sails.log('person search results:',require('util').inspect(report.data.searchPeople.results, {depth:null})); - let foundPerson = report.data.searchPeople.results[0]; //sails.log('Found person:',foundPerson); - let foundPosition = foundPerson && foundPerson.currentPositions && foundPerson.currentPositions.length >= 1 ? foundPerson.currentPositions[0] : undefined; + // // sails.log('person search results:',require('util').inspect(report.data.searchPeople.results, {depth:null})); + // let foundPerson = report.data.searchPeople.results[0]; //sails.log('Found person:',foundPerson); + // let foundPosition = foundPerson && foundPerson.currentPositions && foundPerson.currentPositions.length >= 1 ? foundPerson.currentPositions[0] : undefined; - let person; - if (foundPerson) { - person = { - emailAddress: emailAddress? emailAddress : foundPosition && foundPosition.emails[0]? foundPosition.emails[0].value : '', - linkedinUrl: linkedinUrl? linkedinUrl : foundPerson.linkedin.linkedinUrl, - firstName: firstName? firstName : foundPerson.name.first, - lastName: lastName? lastName : foundPerson.name.last, - organization: organization? organization : foundPosition? foundPosition.companyInfo.name : '', - title: foundPosition? foundPosition.title : '', - phone: foundPerson.personalPhones[0] && foundPerson.personalPhones[0].status !== 'Suppressed' ? foundPerson.personalPhones[0].value : '', - }; - }//fi + // let person; + // if (foundPerson) { + // person = { + // emailAddress: emailAddress? emailAddress : foundPosition && foundPosition.emails[0]? foundPosition.emails[0].value : '', + // linkedinUrl: linkedinUrl? linkedinUrl : foundPerson.linkedin.linkedinUrl, + // firstName: firstName? firstName : foundPerson.name.first, + // lastName: lastName? lastName : foundPerson.name.last, + // organization: organization? organization : foundPosition? foundPosition.companyInfo.name : '', + // title: foundPosition? foundPosition.title : '', + // phone: foundPerson.personalPhones[0] && foundPerson.personalPhones[0].status !== 'Suppressed' ? foundPerson.personalPhones[0].value : '', + // }; + // }//fi - // If no person was found, then try and look up the organization by itself. - let employer; - if (foundPosition) { - employer = { - organization: organization? organization : foundPosition.companyInfo.name || '', - numberOfEmployees: foundPosition.companyInfo.numberOfEmployees || 0, - emailDomain:( foundPosition.companyInfo.domain? foundPosition.companyInfo.domain : emailDomain )|| '', - linkedinCompanyPageUrl: foundPosition.companyInfo.linkedinUrl || '', - technologies: foundPosition.companyInfo.technologies? foundPosition.companyInfo.technologies - .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) - .map((tech) => ({ name: tech.name, category: tech.category })) : [] - }; - } else { - let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { - query: `{ searchCompany(input: { - ${organization? 'name: '+ JSON.stringify(organization) : ''} - ${emailDomain? 'domain: '+ JSON.stringify(emailDomain) : ''} - }) { - totalResults - results { - name - domain - country - address - numberOfEmployees - linkedinUrl - technologies { name category parentCategory attributes categories } - } - } - }` - }, { - Authorization: `Basic ${sails.config.custom.iqSecret}`, - 'content-type': 'application/json' - }); - // sails.log('company search report:',report); + // // If no person was found, then try and look up the organization by itself. + // let employer; + // if (foundPosition) { + // employer = { + // organization: organization? organization : foundPosition.companyInfo.name || '', + // numberOfEmployees: foundPosition.companyInfo.numberOfEmployees || 0, + // emailDomain:( foundPosition.companyInfo.domain? foundPosition.companyInfo.domain : emailDomain )|| '', + // linkedinCompanyPageUrl: foundPosition.companyInfo.linkedinUrl || '', + // technologies: foundPosition.companyInfo.technologies? foundPosition.companyInfo.technologies + // .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) + // .map((tech) => ({ name: tech.name, category: tech.category })) : [] + // }; + // } else { + // let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { + // query: `{ searchCompany(input: { + // ${organization? 'name: '+ JSON.stringify(organization) : ''} + // ${emailDomain? 'domain: '+ JSON.stringify(emailDomain) : ''} + // }) { + // totalResults + // results { + // name + // domain + // country + // address + // numberOfEmployees + // linkedinUrl + // technologies { name category parentCategory attributes categories } + // } + // } + // }` + // }, { + // Authorization: `Basic ${sails.config.custom.iqSecret}`, + // 'content-type': 'application/json' + // }); + // // sails.log('company search report:',report); - if (report.errors) { - sails.log.warn('Errors returned from IQ API when attempting to search directly for a matching organization:',report.errors); - } - let foundEmployer = report.data.searchCompany.results[0]; //sails.log(foundEmployer); - if (foundEmployer) { - employer = { - organization: organization? organization : foundEmployer.name || '', - numberOfEmployees: foundEmployer.numberOfEmployees || 0, - emailDomain: emailDomain? emailDomain : foundEmployer.domain || '', - linkedinCompanyPageUrl: foundEmployer.linkedinUrl || '', - technologies: foundEmployer.technologies? foundEmployer.technologies - .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) - .map((tech) => ({ name: tech.name, category: tech.category })) : [] - };// process.stdout.write(JSON.stringify(employer.technologies,0,2)); - } - }//fi + // if (report.errors) { + // sails.log.warn('Errors returned from IQ API when attempting to search directly for a matching organization:',report.errors); + // } + // let foundEmployer = report.data.searchCompany.results[0]; //sails.log(foundEmployer); + // if (foundEmployer) { + // employer = { + // organization: organization? organization : foundEmployer.name || '', + // numberOfEmployees: foundEmployer.numberOfEmployees || 0, + // emailDomain: emailDomain? emailDomain : foundEmployer.domain || '', + // linkedinCompanyPageUrl: foundEmployer.linkedinUrl || '', + // technologies: foundEmployer.technologies? foundEmployer.technologies + // .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) + // .map((tech) => ({ name: tech.name, category: tech.category })) : [] + // };// process.stdout.write(JSON.stringify(employer.technologies,0,2)); + // } + // }//fi return { - person, - employer + person: undefined, + employer: undefined }; } From 2ba0f015027a9cc19a11749541cec86e024aa199 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 29 Apr 2024 16:40:57 -0500 Subject: [PATCH 72/83] Website: update personalization on homepage and endpoint ops (#18616) Closes: https://github.com/fleetdm/confidential/issues/6336 Changes: - Updated personalization on the homepage and the endpoint ops page --- website/api/controllers/view-endpoint-ops.js | 6 +- website/assets/styles/pages/homepage.less | 28 ++- website/views/pages/endpoint-ops.ejs | 8 +- website/views/pages/homepage.ejs | 211 ++++++++++++++----- 4 files changed, 182 insertions(+), 71 deletions(-) diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index c9a860a64f..ec913c6972 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -26,9 +26,9 @@ module.exports = { // 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']; - if(this.req.session.primaryBuyingSituation === 'eo-it'){ - testimonialOrderForThisPage = ['Eric Tan', 'Tom Larkin', 'Nick Fohs', 'Harrison Ravazzolo', 'Wes Whetstone', 'Chandra Majumdar', 'Erik Gomez', 'Mike Arpaia', 'Kenny Botelho']; - } else if(this.req.session.primaryBuyingSituation === 'eo-security'){ + 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']; + } else if(['eo-security', 'vm'].includes(this.req.session.primaryBuyingSituation)){ testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar']; } // Filter the testimonials by product category and the filtered list we built above. diff --git a/website/assets/styles/pages/homepage.less b/website/assets/styles/pages/homepage.less index 249fdf7441..455e28d27d 100644 --- a/website/assets/styles/pages/homepage.less +++ b/website/assets/styles/pages/homepage.less @@ -132,7 +132,7 @@ &:hover { box-shadow: 0px 4px 16px 0px #E2E4EA; } - &:first-of-type { + &.nick-fohs { [purpose='testimonial-video'] { background: url('/images/video-testimonial-thumbnail-nick-fohs-160x120@2x.png'); background-position: center; @@ -141,7 +141,7 @@ margin-right: 20px; margin-left: 0px; } - &:last-of-type { + &.austin-anderson { [purpose='testimonial-video'] { background: url('/images/video-testimonial-thumbnail-austin-anderson-160x120@2x.png'); background-position: center; @@ -730,22 +730,27 @@ [purpose='category-text-block'] { max-width: 410px; } + [purpose='platform-block'] { + .left { + margin-right: 24px; + } + .right { + margin-left: 24px; + } + } [purpose='endpoint-ops-image'] { - margin-right: 24px; img { height: auto; width: 100%; } } [purpose='device-management-image'] { - margin-left: 24px; img { height: auto; width: 100%; } } [purpose='vuln-management-image'] { - margin-left: 24px; img { height: auto; width: 100%; @@ -935,9 +940,18 @@ width: 100%; } } + [purpose='platform-block'] { + .left { + margin-right: auto; + margin-left: auto; + } + .right { + margin-right: auto; + margin-left: auto; + } + } [purpose='endpoint-ops-image'] { - margin-right: auto; - margin-left: auto; + margin-bottom: 20px; img { width: 100%; diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index 59b640323c..152667a782 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -11,14 +11,14 @@ A device verifying compliance for every endpoint

- <% if(primaryBuyingSituation === 'eo-it') { %> + <% if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) { %> Pulse check anything

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

Ship data to any platform

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

Automate anything

Remotely run scripts and prompts to complete tasks on every kind of computer, including Linux.

- <% } else if(primaryBuyingSituation === 'eo-security') { %> + <% } else if(['eo-security', 'vm'].includes(primaryBuyingSituation)) { %> Osquery on easy mode

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

Pulse check anything @@ -71,11 +71,11 @@
<% } %>
- <%if(primaryBuyingSituation === 'eo-security'){%> + <%if(['eo-security', 'vm'].includes(primaryBuyingSituation)){%>
PlayPlay video
- <%} else if(primaryBuyingSituation === 'eo-it') {%> + <%} else if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) {%>
PlayPlay video
diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index a0a7fc6269..bd977ba42f 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -27,26 +27,30 @@ <%/* Testimonial videos */%>
-
-
- Play video + <% if(['mdm', 'eo-it'].includes(primaryBuyingSituation)) {%> +
+
+ Play video +
+
+

“Fleet lets us to be as lightweight as possible while surfacing data to other teams and partners.”

+

Nick Fohs

+

Systems and IT infrastructure manager

+
-
-

“Fleet lets us to be as lightweight as possible while surfacing data to other teams and partners.”

-

Nick Fohs

-

Systems and IT infrastructure manager

+ <%} else if(['vm', 'eo-security'].includes(primaryBuyingSituation)) {%> +
+
+ Play video +
+
+

“We can build it exactly the way we want it. Which is just not possible on other platforms.”

+

Austin Anderson

+

Cybersecurity team senior manager

+
-
-
-
- Play video -
-
-

“We can build it exactly the way we want it. Which is just not possible on other platforms.”

-

Austin Anderson

-

Cybersecurity team senior manager

-
-
+ <%}%> + <% %>
@@ -79,53 +83,146 @@
<% } %>
-
- <%/* Endpoint ops block */%> -
-
-

Endpoint ops

-

Understand your computers

-

Use a consistent interface to deploy, update, and manage thousands of workstations and servers with open standards and data.

-
- Start with endpoint ops +
+ <% if(!primaryBuyingSituation){ %> + <%/* Endpoint ops block */%> +
+
+

Endpoint ops

+

Understand your computers

+

Use a consistent interface to deploy, update, and manage thousands of workstations and servers with open standards and data.

+ +
+
+ Endpoint ops
-
- Endpoint ops -
-
- - <%/* Device management block */%> -
-
- Operating systems entering a glass device management dome -
-
-

Device management

-

Manage everything in one place

-

Modernize your device management practice with a single, open platform for Apple, Windows, and Linux.

-
- Start with device management + <%/* Device management block */%> +
+
+ Operating systems entering a glass device management dome +
+
+

Device management

+

Manage everything in one place

+

Modernize your device management practice with a single, open platform for Apple, Windows, and Linux.

+
-
- - <%/* Vulnerability management block */%> -
-
-

Vulnerability management

-

Build the vulnerability program you actually want

-

Show how quickly zero days and "celebrity CVEs" are resolved and which teams need extra help. Use scoring, known exploit feeds, and live, down-to-the-chip data about your own environment to prioritize, export, automate, and monitor mitigations of everyday vulnerabilities in operating systems, software packages, browsers, and plugins, anywhere.

-
- Start with vuln management + <%/* Vulnerability management block */%> +
+
+

Vulnerability management

+

Build the vulnerability program you actually want

+

Show how quickly zero days and "celebrity CVEs" are resolved and which teams need extra help. Use scoring, known exploit feeds, and live, down-to-the-chip data about your own environment to prioritize, export, automate, and monitor mitigations of everyday vulnerabilities in operating systems, software packages, browsers, and plugins, anywhere.

+ +
+
+ An orb being scanned for vulnerabilities
-
- An orb being scanned for vulnerabilities -
-
+ <% } else if(['eo-it', 'eo-security'].includes(primaryBuyingSituation)) {%> + <%/* Endpoint ops block */%> +
+
+

Endpoint ops

+

Understand your computers

+

Use a consistent interface to deploy, update, and manage thousands of workstations and servers with open standards and data.

+ +
+
+ Endpoint ops +
+
+ <%if(primaryBuyingSituation === 'eo-it'){ %> + <%/* Device management block */%> +
+
+ Operating systems entering a glass device management dome +
+
+

Device management

+

Manage everything in one place

+

Modernize your device management practice with a single, open platform for Apple, Windows, and Linux.

+ +
+
+ <% } else {%> + <%/* Vulnerability management block */%> +
+
+ An orb being scanned for vulnerabilities +
+
+

Vulnerability management

+

Build the vulnerability program you actually want

+

Show how quickly zero days and "celebrity CVEs" are resolved and which teams need extra help. Use scoring, known exploit feeds, and live, down-to-the-chip data about your own environment to prioritize, export, automate, and monitor mitigations of everyday vulnerabilities in operating systems, software packages, browsers, and plugins, anywhere.

+ +
+
+ <% } %> + <% } else if(['vm', 'mdm'].includes(primaryBuyingSituation)) {%> + + <%if(primaryBuyingSituation === 'mdm'){ %> + <%/* Device management block */%> +
+
+ Operating systems entering a glass device management dome +
+
+

Device management

+

Manage everything in one place

+

Modernize your device management practice with a single, open platform for Apple, Windows, and Linux.

+ +
+
+ <% } else {%> + <%/* Vulnerability management block */%> +
+
+ An orb being scanned for vulnerabilities +
+
+

Vulnerability management

+

Build the vulnerability program you actually want

+

Show how quickly zero days and "celebrity CVEs" are resolved and which teams need extra help. Use scoring, known exploit feeds, and live, down-to-the-chip data about your own environment to prioritize, export, automate, and monitor mitigations of everyday vulnerabilities in operating systems, software packages, browsers, and plugins, anywhere.

+ +
+
+ <% } %> + +
+
+

Endpoint ops

+

Understand your computers

+

Use a consistent interface to deploy, update, and manage thousands of workstations and servers with open standards and data.

+ +
+
+ Endpoint ops +
+
+ <% } %> <%/* Integration cards */%>

Connect your favorite tools

From 27a4071a89a3f8f71e97944e2f6f0fb466d2e6bc Mon Sep 17 00:00:00 2001 From: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:58:17 -0500 Subject: [PATCH 73/83] Make terraform match prod RDS versions (#18621) --- infrastructure/dogfood/terraform/aws-tf-module/free.tf | 3 ++- infrastructure/dogfood/terraform/aws-tf-module/main.tf | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/dogfood/terraform/aws-tf-module/free.tf b/infrastructure/dogfood/terraform/aws-tf-module/free.tf index 4efc3999b9..e87c2e8e77 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/free.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/free.tf @@ -35,7 +35,8 @@ module "free" { subnets = module.main.vpc.database_subnets } redis_config = { - name = local.customer_free + name = local.customer_free + engine_version = "8.0.mysql_aurora.3.05.2" log_delivery_configuration = [ { destination = "dogfood-free-redis-logs" diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf index 2b0056fc79..a3c3614abb 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf @@ -70,6 +70,7 @@ module "main" { } rds_config = { name = local.customer + engine_version = "8.0.mysql_aurora.3.05.2" snapshot_identifier = "arn:aws:rds:us-east-2:611884880216:cluster-snapshot:a2023-03-06-pre-migration" db_parameters = { # 8mb up from 262144 (256k) default From 9bd87ca87f1f38af32e230739e061746c127009e Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Mon, 29 Apr 2024 18:00:43 -0500 Subject: [PATCH 74/83] Bump dogfood terraform to 8.0.mysql_aurora.3.05.2 (#18620) --- infrastructure/dogfood/terraform/aws/rds.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/dogfood/terraform/aws/rds.tf b/infrastructure/dogfood/terraform/aws/rds.tf index 10e0fb512a..bd37ea9cf9 100644 --- a/infrastructure/dogfood/terraform/aws/rds.tf +++ b/infrastructure/dogfood/terraform/aws/rds.tf @@ -67,7 +67,7 @@ module "aurora_mysql" { name = "${local.name}-mysql-iam" engine = "aurora-mysql" - engine_version = "8.0.mysql_aurora.3.02.0" + engine_version = "8.0.mysql_aurora.3.05.2" instance_type = var.db_instance_type_writer instance_type_replica = var.db_instance_type_reader From a4bd6aad049e3d3f9561ab6d0895ddacaa769a20 Mon Sep 17 00:00:00 2001 From: Robert Fairburn <8029478+rfairburn@users.noreply.github.com> Date: Mon, 29 Apr 2024 18:41:11 -0500 Subject: [PATCH 75/83] engine_version in wrong place (#18624) --- infrastructure/dogfood/terraform/aws-tf-module/free.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/dogfood/terraform/aws-tf-module/free.tf b/infrastructure/dogfood/terraform/aws-tf-module/free.tf index e87c2e8e77..ec6aeaa454 100644 --- a/infrastructure/dogfood/terraform/aws-tf-module/free.tf +++ b/infrastructure/dogfood/terraform/aws-tf-module/free.tf @@ -25,6 +25,7 @@ module "free" { } rds_config = { name = local.customer_free + engine_version = "8.0.mysql_aurora.3.05.2" snapshot_identifier = "arn:aws:rds:us-east-2:611884880216:cluster-snapshot:a2023-03-06-pre-migration" db_parameters = { # 8mb up from 262144 (256k) default @@ -35,8 +36,7 @@ module "free" { subnets = module.main.vpc.database_subnets } redis_config = { - name = local.customer_free - engine_version = "8.0.mysql_aurora.3.05.2" + name = local.customer_free log_delivery_configuration = [ { destination = "dogfood-free-redis-logs" From b09d34fa4b8065241191511429908f381b41646d Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Mon, 29 Apr 2024 19:01:47 -0500 Subject: [PATCH 76/83] Website: Quick and dirty fix for signups (#18622) Eric, please test fully before merging, this is just the quick and dirty cut. --- website/api/helpers/iq/get-enriched.js | 70 +++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index 320ce6fda5..6ab649b526 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -38,7 +38,6 @@ module.exports = { numberOfEmployees: 'number', emailDomain: 'string', linkedinCompanyPageUrl: 'string', - technologies: [{name: 'string', category: 'string'}] } } }, @@ -46,8 +45,64 @@ module.exports = { }, - fn: async function () { - // fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { + fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { + + // curl -X POST "https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter" + // -H "accept: application/json" + // -H "Authorization: Bearer {JWT}" + // -H "Content-Type: application/json" + // -d "{\"location\":\"United States\",\"industry\":\"Information Technology\", + // \"last_updated_gte\":\"2022-05-01 00:00:00\"}" + + // console.log(emailAddress,linkedinUrl,firstName,lastName,organization); + + sails.log.verbose('ignoring linkedinUrl, firstName, and lastName for now...', linkedinUrl,firstName,lastName); + + let emailDomain = ''; + if (emailAddress) { + emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; + }//fi + + // [?] https://dashboard.coresignal.com/get-started + let searchBy = {}; + if (emailAddress) { + searchBy.website = emailDomain; + } + if (organization) { + searchBy.name = organization; + } + let matchingIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter', searchBy, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }); + // console.log('matches:',matchingIds); + let matchingId = matchingIds[0]; + if (!matchingId) { + return { + person: undefined, + employer: undefined + }; + }//• (TODO: replace this temporary hack with something nicer, just prioritizing the important part) + require('assert')(matchingId); + + let matchingOrgRecord = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/company/collect/'+encodeURIComponent(matchingId), {}, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }); + + // console.log(report); + return { + // TODO: the rest + employer: { + name: matchingOrgRecord.name, + numberOfEmployees: matchingOrgRecord.employees_count, + emailDomain: emailDomain, + linkedinCompanyPageUrl: matchingOrgRecord.canonical_url, + } + }; + + + // require('assert')(sails.config.custom.iqSecret); // let RX_TECHNOLOGY_CATEGORIES = /(device|security|endpoint|configuration management|data management platforms|mobility management|identity|information technology|IT$|employee experience|apple)/i; @@ -55,7 +110,6 @@ module.exports = { // // [?] https://developer.leadiq.com/#query-searchPeople // // [?] https://developer.leadiq.com/#definition-SearchPeopleInput // // [?] https://graphql.org/learn/serving-over-http/ - // let emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; // let searchExpr = `{ // ${emailAddress? 'email: '+ JSON.stringify(emailAddress) : ''} @@ -179,10 +233,10 @@ module.exports = { // } // }//fi - return { - person: undefined, - employer: undefined - }; + // return { + // person: undefined, + // employer: undefined + // }; } From 7259ec64731050edbe0100d287d5127d9b8f0bec Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 29 Apr 2024 19:20:00 -0500 Subject: [PATCH 77/83] Website: Update save-questionnaire-progress (#18625) Related to https://github.com/fleetdm/confidential/issues/6357 Changes: - Updated `save-questionnaire-progress` to only call `sails.helpers.salesforce.updateOrCreateContactAndAccount` is called when a user's `psychologicalStage` changes. --- .../save-questionnaire-progress.js | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/website/api/controllers/save-questionnaire-progress.js b/website/api/controllers/save-questionnaire-progress.js index 9be1a061ce..36794f96f6 100644 --- a/website/api/controllers/save-questionnaire-progress.js +++ b/website/api/controllers/save-questionnaire-progress.js @@ -174,16 +174,17 @@ module.exports = { }//fi - - - await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ - emailAddress: this.req.me.emailAddress, - firstName: this.req.me.firstName, - lastName: this.req.me.lastName, - 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, - }); + // Only update CRM records if the user's psychological stage changes. + if(currentStep !== userRecord.currentStep){ + await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ + emailAddress: this.req.me.emailAddress, + firstName: this.req.me.firstName, + lastName: this.req.me.lastName, + 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, + }); + } // TODO: send all other answers to Salesforce (when there are fields for them) // await sails.helpers.http.post.with({ From 31c67130d4babfc0191bc0e5c7fb11640fa67fbc Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 29 Apr 2024 23:37:09 -0500 Subject: [PATCH 78/83] Website: Update searchBy criteria in get-enriched helper (#18626) Changes: - Removed the organization name search in the get-enriched helper to improve organization enrichment. --- website/api/helpers/iq/get-enriched.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index 6ab649b526..d207a38ed8 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -56,7 +56,7 @@ module.exports = { // console.log(emailAddress,linkedinUrl,firstName,lastName,organization); - sails.log.verbose('ignoring linkedinUrl, firstName, and lastName for now...', linkedinUrl,firstName,lastName); + sails.log.verbose('ignoring linkedinUrl, firstName, and lastName for now...', linkedinUrl,firstName,lastName,organization); let emailDomain = ''; if (emailAddress) { @@ -64,13 +64,17 @@ module.exports = { }//fi // [?] https://dashboard.coresignal.com/get-started + // FUTURE: handle cases where the a website returns more than one result (e.g, sailsjs.com returns a result for Treeline and for The Sails Company) let searchBy = {}; if (emailAddress) { searchBy.website = emailDomain; } - if (organization) { - searchBy.name = organization; - } + // Note: If a provided organization name contains one or more words that are not in the company's name on LinkedIn, searching by name and emailDomain can return nothing + // in cases where searching by email domain alone returns a result. + // e.g, {emailAddress: 'foo@fleetdm.com', organization: 'Fleetdm'} returns no results, but {emailAddress: 'foo@fleetdm.com', organization: 'Device'} returns the correct information. + // if (organization) { + // searchBy.name = organization; + // } let matchingIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter', searchBy, { Authorization: `Bearer ${sails.config.custom.iqSecret}`, 'content-type': 'application/json' From cdbe6055d1be13c7449d652f8fc579f497ea5e36 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Tue, 30 Apr 2024 01:03:59 -0500 Subject: [PATCH 79/83] Website: Improve enrichment (#18627) --- website/api/helpers/iq/get-enriched.js | 367 +++++++++--------- .../update-or-create-contact-and-account.js | 8 +- 2 files changed, 181 insertions(+), 194 deletions(-) diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index d207a38ed8..7544746910 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -7,6 +7,14 @@ module.exports = { description: 'Search for the contact indicated and return enriched data.', + extendedDescription: `Note about coresignal.com from their FAQ: + Q: Do you have emails or phone numbers in your database? + A: No, we don't have emails or phone numbers. We only hold publicly available data on companies and professionals.`, + + + moreInfoUrl: 'https://coresignal.com/faq/', + + inputs: { emailAddress: { type: 'string', defaultsTo: '', }, @@ -25,13 +33,11 @@ module.exports = { outputDescription: 'All available, enriched info about this person and their current employer.', outputType: { person: { - emailAddress: 'string', linkedinUrl: 'string', firstName: 'string', lastName: 'string', organization: 'string', title: 'string', - phone: 'string', }, employer: { organization: 'string', @@ -47,203 +53,184 @@ module.exports = { fn: async function ({emailAddress,linkedinUrl,firstName,lastName,organization}) { - // curl -X POST "https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter" - // -H "accept: application/json" - // -H "Authorization: Bearer {JWT}" - // -H "Content-Type: application/json" - // -d "{\"location\":\"United States\",\"industry\":\"Information Technology\", - // \"last_updated_gte\":\"2022-05-01 00:00:00\"}" + require('assert')(sails.config.custom.iqSecret);// FUTURE: Rename this config - // console.log(emailAddress,linkedinUrl,firstName,lastName,organization); + let RX_PROTOCOL_AND_COMMON_SUBDOMAINS = /^https?\:\/\/(www\.|about\.)*/; - sails.log.verbose('ignoring linkedinUrl, firstName, and lastName for now...', linkedinUrl,firstName,lastName,organization); + sails.log.verbose('Enriching from…', emailAddress,linkedinUrl,firstName,lastName,organization); - let emailDomain = ''; + // Gather initial information that is obtainable just from parsing provided inputs. + let emailDomain; if (emailAddress) { - emailDomain = emailAddress.match(/@([^@]+)$/) && emailAddress.match(/@([^@]+)$/)[1] || ''; + let matches = emailAddress.match(/@([^@]+)$/); + if (Array.isArray(matches)) { + emailDomain = matches[1] || undefined; + } }//fi - // [?] https://dashboard.coresignal.com/get-started - // FUTURE: handle cases where the a website returns more than one result (e.g, sailsjs.com returns a result for Treeline and for The Sails Company) - let searchBy = {}; - if (emailAddress) { - searchBy.website = emailDomain; - } - // Note: If a provided organization name contains one or more words that are not in the company's name on LinkedIn, searching by name and emailDomain can return nothing - // in cases where searching by email domain alone returns a result. - // e.g, {emailAddress: 'foo@fleetdm.com', organization: 'Fleetdm'} returns no results, but {emailAddress: 'foo@fleetdm.com', organization: 'Device'} returns the correct information. - // if (organization) { - // searchBy.name = organization; - // } - let matchingIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter', searchBy, { - Authorization: `Bearer ${sails.config.custom.iqSecret}`, - 'content-type': 'application/json' - }); - // console.log('matches:',matchingIds); - let matchingId = matchingIds[0]; - if (!matchingId) { - return { - person: undefined, - employer: undefined - }; - }//• (TODO: replace this temporary hack with something nicer, just prioritizing the important part) - require('assert')(matchingId); - - let matchingOrgRecord = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/company/collect/'+encodeURIComponent(matchingId), {}, { - Authorization: `Bearer ${sails.config.custom.iqSecret}`, - 'content-type': 'application/json' - }); - - // console.log(report); - return { - // TODO: the rest - employer: { - name: matchingOrgRecord.name, - numberOfEmployees: matchingOrgRecord.employees_count, - emailDomain: emailDomain, - linkedinCompanyPageUrl: matchingOrgRecord.canonical_url, + let linkedinPersonIdOrUrlSlug; + if (linkedinUrl) { + let matches = linkedinUrl.match(/linkedin\.com\/in\/([^/]+)\/?$/); + if (Array.isArray(matches)) { + linkedinPersonIdOrUrlSlug = matches[1] || undefined; } + }//fi + + + // If no linkedin URL was provided for the person, then also do a website+name+orgName search + // vs contacts to try and locate the person's linkedin URL. + // + // [?] Why? It provides us with a better unique id than an email. For example, consider + // how everyone has more than one email. This way, we can avoid sending any emails that + // people might experience as "spam", even if they unsubscribe from a different email. + if (!linkedinPersonIdOrUrlSlug && (firstName || lastName || emailAddress)) { + let searchBy = {}; + if (firstName && !lastName) { + searchBy.name = firstName; + } else if (!firstName && lastName) { + searchBy.name = lastName; + } else if (firstName && lastName) { + searchBy.name = firstName + ' ' + lastName; + } else { + searchBy.name = _.startCase(emailAddress.replace(/@[^@]+$/,'').replace(/\./g,' ').replace(/[0-9\-]/g,'')); + } + if (emailDomain) { + searchBy.experience_company_website_url = emailDomain;//eslint-disable-line camelcase + searchBy.active_experience = true;//eslint-disable-line camelcase + }//fi + if (organization) { + searchBy.experience_company_name = organization;//eslint-disable-line camelcase + searchBy.active_experience = true;//eslint-disable-line camelcase + }//fi + if (Object.keys(searchBy).length >= 1) { + // [?] https://dashboard.coresignal.com/get-started + let matchingLinkedinPersonIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/member/search/filter', searchBy, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).tolerate((err)=>{ + sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); + return []; + }); + linkedinPersonIdOrUrlSlug = matchingLinkedinPersonIds[0]; + }//fi + }//fi + + let person; + let matchingLinkedinCompanyPageId; + + if (linkedinPersonIdOrUrlSlug) { + // [?] https://dashboard.coresignal.com/get-started + let matchingPersonInfo = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/member/collect/'+encodeURIComponent(linkedinPersonIdOrUrlSlug), {}, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).tolerate((err)=>{ + sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); + return undefined; + }); + + if (matchingPersonInfo) { + + require('assert')(Array.isArray(matchingPersonInfo.member_experience_collection)); + let matchingWorkExperience = ( + matchingPersonInfo.member_experience_collection.filter((workExperience) => + !workExperience.deleted && + workExperience.order_in_profile === 1 && + !workExperience.date_to + // FUTURE: Be smarter by also trying to match the stated organization, if one is provided, for the edge case where someone has multiple current positions. + ) + )[0]; + + let matchedOrganizationName; + let matchedTitle; + if (matchingWorkExperience) { + matchedOrganizationName = matchingWorkExperience.company_name; + matchedTitle = matchingWorkExperience.title; + matchingLinkedinCompanyPageId = matchingWorkExperience.company_id;// « save for use below + } + + person = { + linkedinUrl: matchingPersonInfo.canonical_url.replace(RX_PROTOCOL_AND_COMMON_SUBDOMAINS,''), + firstName: matchingPersonInfo.first_name, + lastName: matchingPersonInfo.last_name, + organization: matchedOrganizationName || '', + title: matchedTitle || '' + }; + + if (linkedinUrl && person.linkedinUrl && person.linkedinUrl !== linkedinUrl) { + sails.log.warn(`Unexpected result when enriching: Matched linkedin URL for person (${person.linkedinUrl}) does not equal the provided linkedin URL (${linkedinUrl})`); + }//fi + if (firstName && person.firstName && person.firstName !== firstName) { + sails.log.warn(`Unexpected result when enriching: Matched current firstName for person (${person.firstName}) does not equal the provided "firstName" (${firstName})`); + }//fi + if (lastName && person.lastName && person.lastName !== lastName) { + sails.log.warn(`Unexpected result when enriching: Matched current lastName for person (${person.lastName}) does not equal the provided "lastName" (${lastName})`); + }//fi + if (organization && person.organization && person.organization !== organization) { + sails.log.warn(`Unexpected result when enriching: Matched current TOP organization for person (${person.organization}) does not equal the provided "organization" (${organization})`); + }//fi + }//fi + }//fi + + + + + // Now look up the employer. + // + // [?] Either use the matched linkedin company page ID from above, + // or if no match, then try to find the linkedin company page ID + // by other means. If nothing works, then give up and don't enrich. + if (!matchingLinkedinCompanyPageId) { + let searchBy = {}; + if (emailDomain) { + searchBy.website = emailDomain; + }//fi + if (organization) { + searchBy.name = organization; + }//fi + if (Object.keys(searchBy).length >= 1) { + // [?] https://dashboard.coresignal.com/get-started + let matchingLinkedinCompanyPageIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter', searchBy, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).tolerate((err)=>{ + sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); + return []; + }); + matchingLinkedinCompanyPageId = matchingLinkedinCompanyPageIds[0]; + }//fi + }//fi + + let employer; + if (matchingLinkedinCompanyPageId) { + // [?] https://dashboard.coresignal.com/get-started + let matchingCompanyPageInfo = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/company/collect/'+encodeURIComponent(matchingLinkedinCompanyPageId), {}, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).tolerate((err)=>{ + sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); + return undefined; + }); + if (matchingCompanyPageInfo) { + employer = { + organization: matchingCompanyPageInfo.name, + numberOfEmployees: matchingCompanyPageInfo.employees_count, + emailDomain: require('url').parse(matchingCompanyPageInfo.website).hostname.replace(RX_PROTOCOL_AND_COMMON_SUBDOMAINS,''), + linkedinCompanyPageUrl: matchingCompanyPageInfo.canonical_url.replace(RX_PROTOCOL_AND_COMMON_SUBDOMAINS,''), + }; + if (organization && employer.organization && employer.organization !== organization) { + sails.log.warn(`Unexpected result when enriching: Matched organization name (${employer.organization}) does not equal the provided "organization" (${organization})`); + }//fi + if (emailDomain && employer.emailDomain && employer.emailDomain !== emailDomain) { + sails.log.warn(`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 + }//fi + }//fi + + return { + person, + employer }; - - - // require('assert')(sails.config.custom.iqSecret); - - // let RX_TECHNOLOGY_CATEGORIES = /(device|security|endpoint|configuration management|data management platforms|mobility management|identity|information technology|IT$|employee experience|apple)/i; - - // // [?] https://developer.leadiq.com/#query-searchPeople - // // [?] https://developer.leadiq.com/#definition-SearchPeopleInput - // // [?] https://graphql.org/learn/serving-over-http/ - - // let searchExpr = `{ - // ${emailAddress? 'email: '+ JSON.stringify(emailAddress) : ''} - // ${linkedinUrl? 'linkedinUrl: '+ JSON.stringify(linkedinUrl) : ''} - // ${firstName? 'firstName: '+ JSON.stringify(firstName) : ''} - // ${lastName? 'lastName: '+ JSON.stringify(lastName) : ''} - // ${organization || emailDomain ? (`company: { - // ${organization? 'name: '+ JSON.stringify(organization) : ''} - // ${emailDomain? 'domain: '+ JSON.stringify(emailDomain)+' '+'emailDomain: '+ JSON.stringify(emailDomain) : ''} - // searchInPastCompanies: false - // strict: false - // }`) : ''} - // }`; //sails.log('GraphQL query:',searchExpr); - // let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { - // query: `{ searchPeople(input: ${searchExpr}) { - // totalResults - // results { - // _id - // name { first last } - // linkedin { linkedinId linkedinUrl status updatedAt } - // profiles { network id username url status updatedAt } - // location { country areaLevel1 city fullAddress type status updatedAt } - // personalPhones { value type status verificationStatus } - // currentPositions { - // title - // emails { value type status } - // phones { value type status verificationStatus } - // companyInfo { - // name - // domain - // country - // address - // linkedinUrl - // numberOfEmployees - // technologies { name category parentCategory attributes categories } - // } - // } - // } - // } - // }`, - // }, { - // Authorization: `Basic ${sails.config.custom.iqSecret}`, - // 'content-type': 'application/json' - // }); - - // if (report.errors) { - // sails.log.warn('Errors returned from IQ API when attempting to search for a matching contact:',report.errors); - // } - - // // sails.log('person search results:',require('util').inspect(report.data.searchPeople.results, {depth:null})); - // let foundPerson = report.data.searchPeople.results[0]; //sails.log('Found person:',foundPerson); - // let foundPosition = foundPerson && foundPerson.currentPositions && foundPerson.currentPositions.length >= 1 ? foundPerson.currentPositions[0] : undefined; - - // let person; - // if (foundPerson) { - // person = { - // emailAddress: emailAddress? emailAddress : foundPosition && foundPosition.emails[0]? foundPosition.emails[0].value : '', - // linkedinUrl: linkedinUrl? linkedinUrl : foundPerson.linkedin.linkedinUrl, - // firstName: firstName? firstName : foundPerson.name.first, - // lastName: lastName? lastName : foundPerson.name.last, - // organization: organization? organization : foundPosition? foundPosition.companyInfo.name : '', - // title: foundPosition? foundPosition.title : '', - // phone: foundPerson.personalPhones[0] && foundPerson.personalPhones[0].status !== 'Suppressed' ? foundPerson.personalPhones[0].value : '', - // }; - // }//fi - - - - - - // // If no person was found, then try and look up the organization by itself. - // let employer; - // if (foundPosition) { - // employer = { - // organization: organization? organization : foundPosition.companyInfo.name || '', - // numberOfEmployees: foundPosition.companyInfo.numberOfEmployees || 0, - // emailDomain:( foundPosition.companyInfo.domain? foundPosition.companyInfo.domain : emailDomain )|| '', - // linkedinCompanyPageUrl: foundPosition.companyInfo.linkedinUrl || '', - // technologies: foundPosition.companyInfo.technologies? foundPosition.companyInfo.technologies - // .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) - // .map((tech) => ({ name: tech.name, category: tech.category })) : [] - // }; - // } else { - // let report = await sails.helpers.http.get('https://api.leadiq.com/graphql', { - // query: `{ searchCompany(input: { - // ${organization? 'name: '+ JSON.stringify(organization) : ''} - // ${emailDomain? 'domain: '+ JSON.stringify(emailDomain) : ''} - // }) { - // totalResults - // results { - // name - // domain - // country - // address - // numberOfEmployees - // linkedinUrl - // technologies { name category parentCategory attributes categories } - // } - // } - // }` - // }, { - // Authorization: `Basic ${sails.config.custom.iqSecret}`, - // 'content-type': 'application/json' - // }); - // // sails.log('company search report:',report); - - // if (report.errors) { - // sails.log.warn('Errors returned from IQ API when attempting to search directly for a matching organization:',report.errors); - // } - // let foundEmployer = report.data.searchCompany.results[0]; //sails.log(foundEmployer); - // if (foundEmployer) { - // employer = { - // organization: organization? organization : foundEmployer.name || '', - // numberOfEmployees: foundEmployer.numberOfEmployees || 0, - // emailDomain: emailDomain? emailDomain : foundEmployer.domain || '', - // linkedinCompanyPageUrl: foundEmployer.linkedinUrl || '', - // technologies: foundEmployer.technologies? foundEmployer.technologies - // .filter((tech) => tech.category.match(RX_TECHNOLOGY_CATEGORIES)) - // .map((tech) => ({ name: tech.name, category: tech.category })) : [] - // };// process.stdout.write(JSON.stringify(employer.technologies,0,2)); - // } - // }//fi - - // return { - // person: undefined, - // employer: undefined - // }; - } - }; - 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 016e1ad32e..fa119965f9 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 @@ -155,13 +155,13 @@ module.exports = { let salesforceContactId; let valuesToSet = {}; - if(emailAddress || enrichmentData.person){ - valuesToSet.Email = emailAddress || enrichmentData.person.emailAddress; + if(emailAddress){ + valuesToSet.Email = emailAddress; } - if(linkedinUrl || enrichmentData.person){ + if(linkedinUrl || (enrichmentData.person && enrichmentData.person.linkedinUrl)){ valuesToSet.LinkedIn_profile__c = linkedinUrl || enrichmentData.person.linkedinUrl;// eslint-disable-line camelcase } - if(enrichmentData.person){ + if(enrichmentData.person && enrichmentData.person.title){ valuesToSet.Title = enrichmentData.person.title; } if(primaryBuyingSituation) { From 8252320c3b9320a11b3b3c21652529435c3a61d6 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Tue, 30 Apr 2024 01:12:53 -0500 Subject: [PATCH 80/83] Fix 500 errors by ensuring organization exists (#18628) --- .../helpers/salesforce/update-or-create-contact-and-account.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fa119965f9..8ad95413ea 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 @@ -73,7 +73,7 @@ module.exports = { await salesforceConnection.login(sails.config.custom.salesforceIntegrationUsername, sails.config.custom.salesforceIntegrationPasskey); let salesforceAccountId; - if(!enrichmentData.employer || !enrichmentData.employer.emailDomain) { + if(!enrichmentData.employer || !enrichmentData.employer.emailDomain || !enrichmentData.employer.organization) { // Special sacraficial meat cave where the contacts with no organization go. // https://fleetdm.lightning.force.com/lightning/r/Account/0014x000025JC8DAAW/view salesforceAccountId = '0014x000025JC8DAAW'; From c01d0ff82ad9a743fa610d3953aef7c8478c2eba Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Tue, 30 Apr 2024 01:40:06 -0500 Subject: [PATCH 81/83] Website: If org name + email specified but no matches, try again (#18629) --- website/api/helpers/iq/get-enriched.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/api/helpers/iq/get-enriched.js b/website/api/helpers/iq/get-enriched.js index 7544746910..c347069380 100644 --- a/website/api/helpers/iq/get-enriched.js +++ b/website/api/helpers/iq/get-enriched.js @@ -196,6 +196,21 @@ module.exports = { sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); return []; }); + + // If name and domain were used for searching the org, yet no matches found, + // try searching again, but this time w/o the org name. + if (matchingLinkedinCompanyPageIds.length === 0 && searchBy.name && searchBy.website) { + delete searchBy.name; + // [?] https://dashboard.coresignal.com/get-started + matchingLinkedinCompanyPageIds = await sails.helpers.http.post('https://api.coresignal.com/cdapi/v1/linkedin/company/search/filter', searchBy, { + Authorization: `Bearer ${sails.config.custom.iqSecret}`, + 'content-type': 'application/json' + }).tolerate((err)=>{ + sails.log.warn(`Failed to enrich (${emailAddress},${linkedinUrl},${firstName},${lastName},${organization}):`,err); + return []; + }); + }//fi + matchingLinkedinCompanyPageId = matchingLinkedinCompanyPageIds[0]; }//fi }//fi From 7bf5dfde5ff20037ffcc0da17a73604add483565 Mon Sep 17 00:00:00 2001 From: Mike McNeil Date: Tue, 30 Apr 2024 03:00:43 -0500 Subject: [PATCH 82/83] Update layout.ejs (#18631) --- website/views/layouts/layout.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index def70805fb..4da963a8be 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -231,7 +231,7 @@