├── .ansible-lint-ignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── documentation-report.md │ └── feature-request.md └── workflows │ ├── ansible-test.yml │ ├── build-docs-and-push-to-ghpages.yml │ ├── pr-build-docs-and-push-to-ghpages.yml │ └── publish-release-to-galaxy.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.md ├── changelogs ├── .plugin-cache.yaml ├── changelog.yaml ├── config.yaml └── fragments │ └── .keep ├── docs └── .git_keep ├── galaxy.yml ├── meta ├── execution-environment.yml └── runtime.yml ├── plugins ├── module_utils │ └── sentinelone │ │ ├── __init__.py │ │ ├── sentinelone_agent_base.py │ │ └── sentinelone_base.py └── modules │ ├── __init__.py │ ├── sentinelone_agent_info.py │ ├── sentinelone_config_overrides.py │ ├── sentinelone_download_agent.py │ ├── sentinelone_filters.py │ ├── sentinelone_groups.py │ ├── sentinelone_path_exclusions.py │ ├── sentinelone_policies.py │ ├── sentinelone_sites.py │ └── sentinelone_upgrade_policies.py ├── requirements.txt ├── requirements.yml ├── roles ├── .git_keep ├── install_agent │ ├── README.md │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── meta │ │ ├── argument_specs.yml │ │ └── main.yml │ ├── tasks │ │ ├── Linux.yml │ │ ├── Windows.yml │ │ └── main.yml │ ├── tests │ │ ├── inventory │ │ └── test.yml │ └── vars │ │ ├── Linux.yml │ │ ├── Windows.yml │ │ └── main.yml └── sentinelone_client_legacy │ ├── .config │ └── ansible-lint.yml │ ├── .gitignore │ ├── .markdownlint.yml │ ├── .travis.yml │ ├── .yamllint │ ├── README.md │ ├── defaults │ └── main.yml │ ├── handlers │ └── main.yml │ ├── meta │ ├── argument_specs.yml │ └── main.yml │ ├── molecule │ ├── .DS_Store │ └── default │ │ ├── INSTALL.rst │ │ ├── README.md │ │ ├── converge.yml │ │ ├── molecule.yml │ │ └── tests │ │ ├── conftest.py │ │ └── test_default.py │ ├── tasks │ ├── digest.yml │ ├── install_debian.yml │ ├── install_redhat.yml │ ├── install_suse.yml │ └── main.yml │ ├── tests │ ├── inventory │ └── test.yml │ └── vars │ ├── debian.yml │ ├── main.yml │ ├── redhat.yml │ └── suse.yml └── tests └── .gitkeep /.ansible-lint-ignore: -------------------------------------------------------------------------------- 1 | # This file contains ignores rule violations for ansible-lint 2 | roles/install_agent/defaults/main.yml var-naming[no-role-prefix] 3 | roles/install_agent/tasks/Linux.yml command-instead-of-module 4 | roles/install_agent/tasks/Linux.yml no-changed-when 5 | roles/install_agent/tasks/Linux.yml var-naming[no-role-prefix] 6 | roles/install_agent/tasks/main.yml var-naming[no-role-prefix] 7 | roles/install_agent/tasks/Windows.yml var-naming[no-role-prefix] 8 | roles/install_agent/vars/Linux.yml var-naming[no-role-prefix] 9 | roles/install_agent/vars/Windows.yml var-naming[no-role-prefix] 10 | roles/install_agent/vars/main.yml var-naming[no-role-prefix] 11 | roles/sentinelone_client_legacy/defaults/main.yml var-naming[no-role-prefix] 12 | roles/sentinelone_client_legacy/tasks/digest.yml command-instead-of-module 13 | roles/sentinelone_client_legacy/tasks/digest.yml var-naming[no-role-prefix] 14 | roles/sentinelone_client_legacy/tasks/install_redhat.yml command-instead-of-module 15 | roles/sentinelone_client_legacy/tasks/install_redhat.yml var-naming[no-role-prefix] 16 | roles/sentinelone_client_legacy/tasks/install_redhat.yml fqcn[action-core] 17 | roles/sentinelone_client_legacy/tasks/install_suse.yml command-instead-of-module 18 | roles/sentinelone_client_legacy/tasks/install_suse.yml var-naming[no-role-prefix] 19 | roles/sentinelone_client_legacy/tasks/main.yml var-naming[no-role-prefix] 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug 4 | title: "\U0001F41B Bug: " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | ##### SUMMARY 15 | 16 | 17 | ##### ISSUE TYPE 18 | - Bug Report 19 | 20 | ##### COMPONENT NAME 21 | 22 | 23 | ##### ANSIBLE VERSION 24 | 25 | ```paste below 26 | 27 | ``` 28 | 29 | ##### COLLECTION VERSION 30 | 33 | ```paste below 34 | 35 | ``` 36 | 37 | ##### DEPENDENCY VERSIONS 38 | 39 | ```paste below 40 | 41 | ``` 42 | 43 | ##### CONFIGURATION 44 | 45 | ```paste below 46 | 47 | ``` 48 | 49 | ##### OS / ENVIRONMENT 50 | 51 | 52 | 53 | ##### STEPS TO REPRODUCE 54 | 55 | 56 | 57 | ```yaml 58 | 59 | ``` 60 | 61 | 62 | 63 | ##### EXPECTED RESULTS 64 | 65 | 66 | 67 | ##### ACTUAL RESULTS 68 | 69 | 70 | 71 | ```paste below 72 | 73 | ``` 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Report 3 | about: Ask us about docs or how we can improve our documentation 4 | title: "\U0001F4D6 Documentation Report: " 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | Ask us about docs or how we can improve our documentation 11 | 12 | 13 | 14 | 15 | 16 | ##### SUMMARY 17 | 18 | 19 | 20 | 21 | ##### ISSUE TYPE 22 | - Documentation Report 23 | 24 | ##### COMPONENT NAME 25 | 26 | 27 | ##### ANSIBLE VERSION 28 | 29 | ```paste below 30 | 31 | ``` 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✨ Feature request" 3 | about: Suggest an idea for this project 4 | title: "✨ Feature request: " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | Suggest an idea for this project 11 | 12 | 13 | 14 | 15 | ##### SUMMARY 16 | 17 | 18 | ##### ISSUE TYPE 19 | - Feature Idea 20 | 21 | ##### COMPONENT NAME 22 | 23 | 24 | ##### ADDITIONAL INFORMATION 25 | 26 | 27 | 28 | ```yaml 29 | 30 | ``` 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/workflows/ansible-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sanity checks 3 | 4 | concurrency: 5 | group: ${{ github.head_ref || github.run_id }} 6 | cancel-in-progress: true 7 | 8 | on: 9 | # Run CI against all pushes (direct commits, also merged PRs), Pull Requests 10 | push: 11 | pull_request: 12 | # Run CI once per day (at 06:00 UTC) 13 | # This ensures that even if there haven't been commits that we are still testing against latest version of ansible-test for each ansible-base version 14 | schedule: 15 | - cron: '0 6 * * *' 16 | env: 17 | NAMESPACE: sva 18 | COLLECTION_NAME: sentinelone 19 | # ANSIBLE_TEST_PREFER_PODMAN: 1 20 | 21 | jobs: 22 | ansible-lint: 23 | uses: ansible/ansible-content-actions/.github/workflows/ansible_lint.yaml@main 24 | with: 25 | args: "--exclude .ansible/" 26 | sanity: 27 | uses: ansible/ansible-content-actions/.github/workflows/sanity.yaml@main 28 | all_green: 29 | if: ${{ always() }} 30 | needs: 31 | - ansible-lint 32 | - sanity 33 | runs-on: ubuntu-latest 34 | steps: 35 | - run: >- 36 | python -c "assert 'failure' not in 37 | set([ 38 | '${{ needs.sanity.result }}', 39 | '${{ needs.ansible-lint.result }}' 40 | ])" 41 | -------------------------------------------------------------------------------- /.github/workflows/build-docs-and-push-to-ghpages.yml: -------------------------------------------------------------------------------- 1 | name: Collection Docs 2 | concurrency: 3 | group: docs-push-${{ github.sha }} 4 | cancel-in-progress: true 5 | on: 6 | push: 7 | branches: 8 | - main 9 | tags: 10 | - '*' 11 | schedule: 12 | - cron: '0 7 * * *' 13 | 14 | jobs: 15 | build-docs: 16 | permissions: 17 | contents: read 18 | name: Build Ansible Docs 19 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main 20 | with: 21 | init-lenient: false 22 | init-fail-on-error: true 23 | collection-name: sva.sentinelone 24 | 25 | publish-docs-gh-pages: 26 | # use to prevent running on forks 27 | if: github.repository == 'svalabs/sva.sentinelone' 28 | permissions: 29 | contents: write 30 | pages: write 31 | id-token: write 32 | needs: [build-docs] 33 | name: Publish Ansible Docs 34 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main 35 | with: 36 | artifact-name: ${{ needs.build-docs.outputs.artifact-name }} 37 | secrets: 38 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/pr-build-docs-and-push-to-ghpages.yml: -------------------------------------------------------------------------------- 1 | name: Collection Docs on pull request 2 | concurrency: 3 | group: docs-pr-${{ github.head_ref }} 4 | cancel-in-progress: true 5 | on: 6 | pull_request_target: 7 | types: [opened, synchronize, reopened, closed] 8 | 9 | env: 10 | GHP_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} 11 | 12 | jobs: 13 | # Validation job runs a strict build to ensure it will fail CI on any mistakes. 14 | validate-docs: 15 | permissions: 16 | contents: read 17 | name: Validate Ansible Docs 18 | if: github.event.action != 'closed' 19 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main 20 | with: 21 | artifact-upload: false 22 | init-lenient: false 23 | init-fail-on-error: true 24 | collection-name: sva.sentinelone 25 | build-ref: refs/pull/${{ github.event.number }}/merge 26 | 27 | # The build job runs with the most lenient settings to ensure the best chance of getting 28 | # a rendered docsite that can be looked at. 29 | build-docs: 30 | permissions: 31 | contents: read 32 | name: Build Ansible Docs 33 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-pr.yml@main 34 | with: 35 | init-lenient: true 36 | init-fail-on-error: false 37 | collection-name: sva.sentinelone 38 | render-file-line: '> * `$` [$](https://${{ github.repository_owner }}.github.io/$ 39 | {{ github.event.repository.name }}/pr/${{ github.event.number }}/$)' 40 | 41 | publish-docs-gh-pages: 42 | # use to prevent running on forks 43 | if: github.repository == 'svalabs/sva.sentinelone' 44 | permissions: 45 | contents: write 46 | pages: write 47 | id-token: write 48 | needs: [build-docs] 49 | name: Publish Ansible Docs 50 | uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main 51 | with: 52 | artifact-name: ${{ needs.build-docs.outputs.artifact-name }} 53 | action: ${{ (github.event.action == 'closed' || needs.build-docs.outputs.changed != 'true') && 'teardown' || 'publish' }} 54 | secrets: 55 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | comment: 58 | permissions: 59 | pull-requests: write 60 | runs-on: ubuntu-latest 61 | needs: [publish-docs-gh-pages, build-docs] 62 | name: PR comments 63 | steps: 64 | - name: PR comment 65 | uses: ansible-community/github-docs-build/actions/ansible-docs-build-comment@main 66 | with: 67 | body-includes: '## Docs Build' 68 | reactions: heart 69 | action: ${{ needs.build-docs.outputs.changed != 'true' && 'remove' || '' }} 70 | on-closed-action: remove 71 | on-merged-body: | 72 | ## Docs Build 📝 73 | 74 | Thank you for contribution!✨ 75 | 76 | This PR has been merged and the docs are now incorporated into `main`: 77 | ${{ env.GHP_BASE_URL }}/branch/main 78 | body: | 79 | ## Docs Build 📝 80 | 81 | Thank you for contribution!✨ 82 | 83 | The docs for **this PR** have been published here: 84 | ${{ env.GHP_BASE_URL }}/pr/${{ github.event.number }} 85 | 86 | You can compare to the docs for the `main` branch here: 87 | ${{ env.GHP_BASE_URL }}/branch/main 88 | 89 | The docsite for **this PR** is also available for download as an artifact from this run: 90 | ${{ needs.build-docs.outputs.artifact-url }} 91 | 92 | File changes: 93 | 94 | ${{ needs.build-docs.outputs.diff-files-rendered }} 95 | 96 | ${{ needs.build-docs.outputs.diff-rendered }} 97 | -------------------------------------------------------------------------------- /.github/workflows/publish-release-to-galaxy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Collection to galaxy 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | release_galaxy: 9 | uses: ansible/ansible-content-actions/.github/workflows/release_galaxy.yaml@main 10 | with: 11 | environment: release 12 | secrets: 13 | ansible_galaxy_api_key: ${{ secrets.ANSIBLE_GALAXY_API_KEY }} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tests/output/ 2 | .idea/ 3 | .vscode/ -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Sva.Sentinelone Release Notes 3 | ============================= 4 | 5 | .. contents:: Topics 6 | 7 | v2.0.5 8 | ====== 9 | 10 | Minor Changes 11 | ------------- 12 | 13 | - add filter on os_type while get request on exclusions 14 | - output URL in failure message of api_call 15 | - when updateing s1 exclusions do put request instead of delete and post 16 | 17 | v2.0.3 18 | ====== 19 | 20 | Release Summary 21 | --------------- 22 | 23 | This is a bgfix release 24 | 25 | Bugfixes 26 | -------- 27 | 28 | - install_agent role: Fixed a bug which broke OpenSUSE compatibility 29 | 30 | v2.0.2 31 | ====== 32 | 33 | Release Summary 34 | --------------- 35 | 36 | This is a bugfix release 37 | 38 | Bugfixes 39 | -------- 40 | 41 | - Reversed changes made in v2.0.1. 42 | - install_agent role: Fixed a bug where idempotency in the 'Windows: Remove agent package from target machine' task was broken. 43 | 44 | v2.0.1 45 | ====== 46 | 47 | Release Summary 48 | --------------- 49 | 50 | Bugfix release 51 | 52 | Bugfixes 53 | -------- 54 | 55 | - Fixed a bug where the install_agent role fails on local tasks if "ansible_connection" var is set in playbook. 56 | 57 | v2.0.0 58 | ====== 59 | 60 | Release Summary 61 | --------------- 62 | 63 | - Added new agent_info module and merged sentinelone_client_legacy from @stdevel. 64 | - Added new `check_console_retries` and `check_console_retry_delay` in install_agent role. 65 | - Switched to ansible-content-actions in pipelines 66 | 67 | Minor Changes 68 | ------------- 69 | 70 | - Pipelines: Switched ansible-content-actions when performing sanity checks, linting and release to ansible galaxy 71 | 72 | Breaking Changes / Porting Guide 73 | -------------------------------- 74 | 75 | - The download_agent modules `state` parameter is no longer available. If you used `state: info` please use the new agent_info module instead. 76 | - `state` parameter has been removed from download_agent module. 77 | 78 | New Modules 79 | ----------- 80 | 81 | - sva.sentinelone.sentinelone_agent_info - Get info about the SentinelOne agent package 82 | 83 | New Roles 84 | --------- 85 | 86 | - sva.sentinelone.sentinelone_client_legacy - Entrypoint for sentinelone_client_legacy role 87 | 88 | v1.1.1 89 | ====== 90 | 91 | Release Summary 92 | --------------- 93 | 94 | Maintenance release 95 | 96 | Bugfixes 97 | -------- 98 | 99 | - install_agent role: Added 'become: true' to necessary linux tasks. It is no longer necessary to use 'become: true' on playbook level. Fixes https://github.com/svalabs/sva.sentinelone/issues/30 100 | - install_agent role: Added missing 'urlencode' filter so special characters like space can be used in site or group names. Fixes https://github.com/svalabs/sva.sentinelone/issues/28 101 | 102 | v1.1.0 103 | ====== 104 | 105 | Release Summary 106 | --------------- 107 | 108 | This is the release v1.1.0 of the ``sva.sentinelone`` collection. It introduces new modules and roles. 109 | Modules: sentinelone_download_agent 110 | Roles: install_agent 111 | 112 | New Modules 113 | ----------- 114 | 115 | - sva.sentinelone.sentinelone_download_agent - Download SentinelOne agent from Management Console 116 | 117 | New Roles 118 | --------- 119 | 120 | - sva.sentinelone.install_agent - A role to download and install SentinelAgent on Windows and Linux hosts 121 | 122 | v1.0.3 123 | ====== 124 | 125 | Release Summary 126 | --------------- 127 | 128 | Increased request timeout and implemented error handling for requests that timed out. 129 | 130 | v1.0.2 131 | ====== 132 | 133 | Release Summary 134 | --------------- 135 | 136 | Added detailed error message to module output if an API call fails 137 | 138 | v1.0.1 139 | ====== 140 | 141 | Release Summary 142 | --------------- 143 | 144 | This is a bugfix release 145 | 146 | Bugfixes 147 | -------- 148 | 149 | - sentinelone_policies module: When a group policy inherited from the site scope was updated with a custom setting, all other settings were reset to the default values. Now the inherited settings are updated by the settings passed to the module and the other inherited settings are retained. 150 | 151 | v1.0.0 152 | ====== 153 | 154 | Release Summary 155 | --------------- 156 | 157 | This is the initial version of the ``sva.sentinelone`` collection 158 | 159 | New Modules 160 | ----------- 161 | 162 | - sva.sentinelone.sentinelone_config_overrides - Manage SentinelOne Config Overrides 163 | - sva.sentinelone.sentinelone_filters - Manage SentinelOne Filters 164 | - sva.sentinelone.sentinelone_groups - Manage SentinelOne Groups 165 | - sva.sentinelone.sentinelone_path_exclusions - Manage SentinelOne Path Exclusions 166 | - sva.sentinelone.sentinelone_policies - Manage SentinelOne Policies 167 | - sva.sentinelone.sentinelone_sites - Manage SentinelOne Sites 168 | - sva.sentinelone.sentinelone_upgrade_policies - Manage SentinelOne Upgrade Policies 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ansible Collection - sva.sentinelone 2 | [![Sanity checks](https://github.com/svalabs/sva.sentinelone/actions/workflows/ansible-test.yml/badge.svg?branch=main)](https://github.com/svalabs/sva.sentinelone/actions/workflows/ansible-test.yml) [![Collection Docs](https://github.com/svalabs/sva.sentinelone/actions/workflows/build-docs-and-push-to-ghpages.yml/badge.svg?branch=main)](https://github.com/svalabs/sva.sentinelone/actions/workflows/build-docs-and-push-to-ghpages.yml) 3 | 4 | ## Description 5 | This is the unofficial SentinelOne Collection provided by [SVA](https://www.sva.de) 6 | 7 | This collection is a community project and is neither provided nor supported by SentinelOne itself. 8 | 9 | It provides several modules which helps to configure and manage SentinelOne Management Consoles. 10 | 11 | ## Included content 12 | 13 | - **Modules**: 14 | - [sentinelone_agent_info](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_agent_info_module.html) 15 | - [sentinelone_config_overrides](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_config_overrides_module.html) 16 | - [sentinelone_download_agent](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_download_agent_module.html) 17 | - [sentinelone_filters](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_filters_module.html) 18 | - [sentinelone_groups](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_groups_module.html) 19 | - [sentinelone_sites](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_sites_module.html) 20 | - [sentinelone_upgrade_policies](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_upgrade_policies_module.html) 21 | - [sentinelone_path_exclusions](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_path_exclusions_module.html) 22 | - [sentinelone_policies](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_policies_module.html) 23 | 24 | - **Roles:** 25 | - [install_agent](roles/install_agent/README.md) 26 | - [sentinelone_client_legacy](roles/sentinelone_client_legacy/README.md) 27 | 28 | ## Requirements 29 | ### Ansible 30 | - ansible >= 8 **or** ansible-core >= 2.15 (Lower versions may work but they have not been tested) 31 | 32 | ### Python 33 | - Python >= 3.9 (Ansible control node requirement) 34 | 35 | ### External 36 | This collection needs the following Python modules: 37 | - deepdiff >= 5.6.0 (Lower versions may work but they have not been tested) 38 | 39 | ## Tested with Ansible and the following Python versions 40 | 41 | Tested Ansible versions: 42 | - 2.15 43 | - 2.16 44 | - 2.17 45 | 46 | Tested Python versions: 47 | - 3.9 48 | - 3.10 49 | - 3.11 50 | - 3.12 51 | 52 | ## Using this collection 53 | ### Installing the collection from Ansible Galaxy 54 | Before using this collection, you need to install it with the Ansible Galaxy command-line tool: 55 | ```bash 56 | ansible-galaxy collection install sva.sentinelone 57 | ``` 58 | 59 | You can also include it in a `requirements.yml` file and install it with `ansible-galaxy collection install -r requirements.yml`, using the format: 60 | ```yaml 61 | --- 62 | collections: 63 | - name: sva.sentinelone 64 | ``` 65 | 66 | Note that if you install the collection from Ansible Galaxy, it will not be upgraded automatically when you upgrade the `ansible` package. To upgrade the collection to the latest available version, run the following command: 67 | ```bash 68 | ansible-galaxy collection install sva.sentinelone --upgrade 69 | ``` 70 | 71 | You can also install a specific version of the collection, for example, if you need to downgrade when something is broken in the latest version (please report an issue in this repository). Use the following syntax to install version `1.0.0`: 72 | 73 | ```bash 74 | ansible-galaxy collection install sva.sentinelone:==1.0.0 75 | ``` 76 | 77 | See [Ansible Using collections](https://docs.ansible.com/ansible/devel/user_guide/collections_using.html) for more details. 78 | 79 | ## Documentation 80 | ### User documentation 81 | The module documentation can be found [here](https://svalabs.github.io/sva.sentinelone/branch/main/collections/index_module.html). 82 | 83 | The role documentation can be found [here](https://svalabs.github.io/sva.sentinelone/branch/main/collections/index_role.html). 84 | 85 | ## Changelog 86 | **v2.0.3**: Bugfix release. Fixed OpenSUSE compatibility 87 | 88 | **v2.0.2**: Bugfix release. Fixed idempotency bug in install_agent role and reverted changes from v2.0.1 89 | 90 | **v2.0.1**: Bugfix release. Fixed a bug where the install_agent role fails on local tasks if "ansible_connection" var is set in playbook. 91 | 92 | **v2.0.0**: 93 | - Added new sentinelone_agent_info module and [@stdevels](https://github.com/stdevel/ansible-sentinelone_client) sentinelone_client role as sentinelone_client_legacy. 94 | - install_agent role: Added configurable retries and delays in the step which checks if the agent appears in the management console. 95 | - **Breaking Changes**: The download_agent modules `state` parameter is no longer available. If you used `state: info` please use the new agent_info module instead. `state` parameter has been removed from download_agent module. 96 | 97 | **v1.1.1**: Bugfix release. Changed privilege escalation behaviour 98 | 99 | **v1.1.0**: Added new sentinelone_download_agent module and install_agent role 100 | 101 | **v1.0.3**: Increased request timeout and implemented error handling for requests that timed out 102 | 103 | **v1.0.2**: Added detailed error message to module output if an API call fails 104 | 105 | **v1.0.1**: Bugfix release 106 | 107 | **v1.0.0**: Initial release 108 | 109 | Detailed Changelog can be found at [CHANGELOG](CHANGELOG.rst) 110 | 111 | ## Todo (help is welcome) 112 | - [ ] Make the modules usable on account scope 113 | - [ ] Unit tests needs to be written 114 | 115 | ## Licensing 116 | The SVA SentinelOne collection is licensed under the GNU General Public License v3.0+. See [LICENSE](LICENSE) for the full license text. 117 | -------------------------------------------------------------------------------- /changelogs/.plugin-cache.yaml: -------------------------------------------------------------------------------- 1 | objects: 2 | role: 3 | install_agent: 4 | description: Entrypoint for install_agent role 5 | name: install_agent 6 | version_added: null 7 | sentinelone_client_legacy: 8 | description: Entrypoint for sentinelone_client_legacy role 9 | name: sentinelone_client_legacy 10 | version_added: 2.0.0 11 | plugins: 12 | become: {} 13 | cache: {} 14 | callback: {} 15 | cliconf: {} 16 | connection: {} 17 | filter: {} 18 | httpapi: {} 19 | inventory: {} 20 | lookup: {} 21 | module: 22 | sentinelone_agent_info: 23 | description: Get info about the SentinelOne agent package 24 | name: sentinelone_agent_info 25 | namespace: '' 26 | version_added: 2.0.0 27 | sentinelone_config_overrides: 28 | description: Manage SentinelOne Config Overrides 29 | name: sentinelone_config_overrides 30 | namespace: '' 31 | version_added: 1.0.0 32 | sentinelone_download_agent: 33 | description: Download SentinelOne agent from Management Console 34 | name: sentinelone_download_agent 35 | namespace: '' 36 | version_added: 1.1.0 37 | sentinelone_filters: 38 | description: Manage SentinelOne Filters 39 | name: sentinelone_filters 40 | namespace: '' 41 | version_added: 1.0.0 42 | sentinelone_groups: 43 | description: Manage SentinelOne Groups 44 | name: sentinelone_groups 45 | namespace: '' 46 | version_added: 1.0.0 47 | sentinelone_path_exclusions: 48 | description: Manage SentinelOne Path Exclusions 49 | name: sentinelone_path_exclusions 50 | namespace: '' 51 | version_added: 1.0.0 52 | sentinelone_policies: 53 | description: Manage SentinelOne Policies 54 | name: sentinelone_policies 55 | namespace: '' 56 | version_added: 1.0.0 57 | sentinelone_sites: 58 | description: Manage SentinelOne Sites 59 | name: sentinelone_sites 60 | namespace: '' 61 | version_added: 1.0.0 62 | sentinelone_upgrade_policies: 63 | description: Manage SentinelOne Upgrade Policies 64 | name: sentinelone_upgrade_policies 65 | namespace: '' 66 | version_added: 1.0.0 67 | netconf: {} 68 | shell: {} 69 | strategy: {} 70 | test: {} 71 | vars: {} 72 | version: 2.0.5 73 | -------------------------------------------------------------------------------- /changelogs/changelog.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ancestor: null 3 | releases: 4 | 1.0.0: 5 | changes: 6 | release_summary: This is the initial version of the ``sva.sentinelone`` collection 7 | fragments: 8 | - v1.0.0.yml 9 | modules: 10 | - description: Manage SentinelOne Config Overrides 11 | name: sentinelone_config_overrides 12 | namespace: '' 13 | - description: Manage SentinelOne Filters 14 | name: sentinelone_filters 15 | namespace: '' 16 | - description: Manage SentinelOne Groups 17 | name: sentinelone_groups 18 | namespace: '' 19 | - description: Manage SentinelOne Path Exclusions 20 | name: sentinelone_path_exclusions 21 | namespace: '' 22 | - description: Manage SentinelOne Policies 23 | name: sentinelone_policies 24 | namespace: '' 25 | - description: Manage SentinelOne Sites 26 | name: sentinelone_sites 27 | namespace: '' 28 | - description: Manage SentinelOne Upgrade Policies 29 | name: sentinelone_upgrade_policies 30 | namespace: '' 31 | release_date: '2022-08-16' 32 | 1.0.1: 33 | changes: 34 | bugfixes: 35 | - 'sentinelone_policies module: When a group policy inherited from the site 36 | scope was updated with a custom setting, all other settings were reset to 37 | the default values. Now the inherited settings are updated by the settings 38 | passed to the module and the other inherited settings are retained.' 39 | release_summary: This is a bugfix release 40 | fragments: 41 | - v1.0.1.yaml 42 | release_date: '2023-01-30' 43 | 1.0.2: 44 | changes: 45 | release_summary: Added detailed error message to module output if an API call 46 | fails 47 | fragments: 48 | - v1.0.2.yml 49 | release_date: '2023-03-08' 50 | 1.0.3: 51 | changes: 52 | release_summary: Increased request timeout and implemented error handling for 53 | requests that timed out. 54 | fragments: 55 | - v1.0.3.yml 56 | release_date: '2023-03-13' 57 | 1.1.0: 58 | changes: 59 | release_summary: 'This is the release v1.1.0 of the ``sva.sentinelone`` collection. 60 | It introduces new modules and roles. 61 | 62 | Modules: sentinelone_download_agent 63 | 64 | Roles: install_agent 65 | 66 | ' 67 | fragments: 68 | - v1.1.0.yml 69 | modules: 70 | - description: Download SentinelOne agent from Management Console 71 | name: sentinelone_download_agent 72 | namespace: '' 73 | objects: 74 | role: 75 | - description: A role to download and install SentinelAgent on Windows and 76 | Linux hosts 77 | name: install_agent 78 | namespace: null 79 | release_date: '2024-03-14' 80 | 1.1.1: 81 | changes: 82 | bugfixes: 83 | - 'install_agent role: Added ''become: true'' to necessary linux tasks. It 84 | is no longer necessary to use ''become: true'' on playbook level. Fixes 85 | https://github.com/svalabs/sva.sentinelone/issues/30' 86 | - 'install_agent role: Added missing ''urlencode'' filter so special characters 87 | like space can be used in site or group names. Fixes https://github.com/svalabs/sva.sentinelone/issues/28' 88 | release_summary: Maintenance release 89 | fragments: 90 | - v1.1.1.yml 91 | release_date: '2024-05-27' 92 | 2.0.0: 93 | changes: 94 | breaking_changes: 95 | - 'The download_agent modules `state` parameter is no longer available. If 96 | you used `state: info` please use the new agent_info module instead.' 97 | - '`state` parameter has been removed from download_agent module.' 98 | minor_changes: 99 | - 'Pipelines: Switched ansible-content-actions when performing sanity checks, 100 | linting and release to ansible galaxy' 101 | release_summary: '- Added new agent_info module and merged sentinelone_client_legacy 102 | from @stdevel. 103 | 104 | - Added new `check_console_retries` and `check_console_retry_delay` in install_agent 105 | role. 106 | 107 | - Switched to ansible-content-actions in pipelines 108 | 109 | ' 110 | fragments: 111 | - v2.0.0.yml 112 | modules: 113 | - description: Get info about the SentinelOne agent package 114 | name: sentinelone_agent_info 115 | namespace: '' 116 | objects: 117 | role: 118 | - description: Entrypoint for sentinelone_client_legacy role 119 | name: sentinelone_client_legacy 120 | namespace: null 121 | release_date: '2024-09-26' 122 | 2.0.1: 123 | changes: 124 | bugfixes: 125 | - Fixed a bug where the install_agent role fails on local tasks if "ansible_connection" 126 | var is set in playbook. 127 | release_summary: Bugfix release 128 | fragments: 129 | - v2.0.1.yml 130 | release_date: '2024-10-29' 131 | 2.0.2: 132 | changes: 133 | bugfixes: 134 | - Reversed changes made in v2.0.1. 135 | - 'install_agent role: Fixed a bug where idempotency in the ''Windows: Remove 136 | agent package from target machine'' task was broken.' 137 | release_summary: This is a bugfix release 138 | fragments: 139 | - v2.0.2.yml 140 | release_date: '2024-11-04' 141 | 2.0.3: 142 | changes: 143 | bugfixes: 144 | - 'install_agent role: Fixed a bug which broke OpenSUSE compatibility' 145 | release_summary: This is a bgfix release 146 | fragments: 147 | - v2.0.3.yml 148 | release_date: '2024-11-21' 149 | 2.0.5: 150 | changes: 151 | minor_changes: 152 | - add filter on os_type while get request on exclusions 153 | - output URL in failure message of api_call 154 | - when updateing s1 exclusions do put request instead of delete and post 155 | fragments: 156 | - v2.0.5.yml 157 | release_date: '2025-02-21' 158 | -------------------------------------------------------------------------------- /changelogs/config.yaml: -------------------------------------------------------------------------------- 1 | changelog_filename_template: ../CHANGELOG.rst 2 | changelog_filename_version_depth: 0 3 | changes_file: changelog.yaml 4 | changes_format: combined 5 | ignore_other_fragment_extensions: true 6 | keep_fragments: false 7 | mention_ancestor: true 8 | new_plugins_after_name: removed_features 9 | notesdir: fragments 10 | prelude_section_name: release_summary 11 | prelude_section_title: Release Summary 12 | sanitize_changelog: true 13 | sections: 14 | - - major_changes 15 | - Major Changes 16 | - - minor_changes 17 | - Minor Changes 18 | - - breaking_changes 19 | - Breaking Changes / Porting Guide 20 | - - deprecated_features 21 | - Deprecated Features 22 | - - removed_features 23 | - Removed Features (previously deprecated) 24 | - - security_fixes 25 | - Security Fixes 26 | - - bugfixes 27 | - Bugfixes 28 | - - known_issues 29 | - Known Issues 30 | title: Sva.Sentinelone 31 | trivial_section_name: trivial 32 | use_fqcn: true 33 | changelog_nice_yaml: true 34 | -------------------------------------------------------------------------------- /changelogs/fragments/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/changelogs/fragments/.keep -------------------------------------------------------------------------------- /docs/.git_keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/docs/.git_keep -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ### REQUIRED 3 | # The namespace of the collection. This can be a company/brand/organization or product namespace under which all 4 | # content lives. May only contain alphanumeric lowercase characters and underscores. Namespaces cannot start with 5 | # underscores or numbers and cannot contain consecutive underscores 6 | namespace: "sva" 7 | 8 | # The name of the collection. Has the same character restrictions as 'namespace' 9 | name: "sentinelone" 10 | 11 | # The version of the collection. Must be compatible with semantic versioning 12 | version: "2.0.5" 13 | 14 | # The path to the Markdown (.md) readme file. This path is relative to the root of the collection 15 | readme: "README.md" 16 | 17 | # A list of the collection's content authors. Can be just the name or in the format 'Full Name (url) 18 | # @nicks:irc/im.site#channel' 19 | authors: 20 | - Marco Wester 21 | 22 | ### OPTIONAL but strongly recommended 23 | # A short summary description of the collection 24 | description: "Collection for Sentinelone Modules" 25 | 26 | # Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only 27 | # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' 28 | # license: 29 | # - "GPL-3.0-or-later" 30 | # The path to the license file for the collection. This path is relative to the root of the collection. This key is 31 | # mutually exclusive with 'license' 32 | license_file: "LICENSE" 33 | 34 | # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character 35 | # requirements as 'namespace' and 'name' 36 | tags: 37 | - agent 38 | - application 39 | - install_agent 40 | - security 41 | - sentinelone 42 | - sentinelone_client 43 | - sentinelone_client_legacy 44 | - sentinelone_config_overrides 45 | - sentinelone_filters 46 | - sentinelone_groups 47 | - sentinelone_path_exclusions 48 | - sentinelone_policies 49 | - sentinelone_sites 50 | - sentinelone_upgrade_policies 51 | 52 | dependencies: 53 | ansible.windows: "*" 54 | 55 | # The URL of the originating SCM repository 56 | repository: "https://github.com/svalabs/sva.sentinelone" 57 | 58 | # The URL to any online docs 59 | documentation: "https://github.com/svalabs/sva.sentinelone/blob/main/README.md" 60 | 61 | # The URL to the homepage of the collection/project 62 | homepage: "https://github.com/svalabs/sva.sentinelone" 63 | 64 | # The URL to the collection issue tracker 65 | issues: "https://github.com/svalabs/sva.sentinelone/issues" 66 | 67 | # A list of file glob-like patterns used to filter any files or directories that should not be included in the build 68 | # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This 69 | # uses 'fnmatch' to match the files or directories. Some directories and files like 'galaxy.yml', '*.pyc', '*.retry', 70 | # and '.git' are always filtered 71 | build_ignore: 72 | - .github/** 73 | - .gitignore 74 | -------------------------------------------------------------------------------- /meta/execution-environment.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 3 3 | dependencies: 4 | python: requirements.txt 5 | galaxy: requirements.yml 6 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | --- 2 | requires_ansible: '>=2.15.0' 3 | -------------------------------------------------------------------------------- /plugins/module_utils/sentinelone/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/plugins/module_utils/sentinelone/__init__.py -------------------------------------------------------------------------------- /plugins/module_utils/sentinelone/sentinelone_agent_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright: (c) 2024, Marco Wester 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | from ansible.module_utils.six.moves.urllib.parse import urlencode 9 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase 10 | from ansible.module_utils.basic import AnsibleModule 11 | 12 | 13 | class SentineloneAgentBase(SentineloneBase): 14 | def __init__(self, module: AnsibleModule): 15 | module.params['site_name'] = module.params['site'] 16 | 17 | module.params['site_name'] = module.params['site'] 18 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 19 | # super Class 20 | super().__init__(module) 21 | 22 | # Set module specific parameters 23 | self.agent_version = module.params["agent_version"] 24 | self.custom_version = module.params["custom_version"] 25 | self.os_type = module.params["os_type"] 26 | self.packet_format = module.params["packet_format"] 27 | self.architecture = module.params["architecture"] 28 | self.download_dir = module.params.get("download_dir", None) 29 | 30 | # Do sanity checks 31 | self.check_sanity(self.os_type, self.packet_format, self.architecture, module) 32 | 33 | @staticmethod 34 | def check_sanity(os_type: str, packet_format: str, architecture: str, module: AnsibleModule): 35 | """ 36 | Check if the passed module arguments are contradicting each other 37 | 38 | :param architecture: OS architecture 39 | :type architecture: str 40 | :param os_type: The specified OS type 41 | :type os_type: str 42 | :param packet_format: The speciefied packet format 43 | :type packet_format: str 44 | :param module: Ansible module for error handling 45 | :type module: AnsibleModule 46 | """ 47 | 48 | if architecture == "aarch64" and os_type != "Linux": 49 | module.fail_json(msg="Error: architecture 'aarch64' needs os_type to be 'Linux'") 50 | 51 | if os_type == 'Windows': 52 | if packet_format not in ['exe', 'msi']: 53 | module.fail_json(msg="Error: 'packet_format' needs to be 'exe' or 'msi' if os_type is 'Windows'") 54 | elif packet_format not in ['deb', 'rpm']: 55 | module.fail_json(msg="Error: 'packet_format' needs to be 'deb' or 'rpm' if os_type is 'Linux'") 56 | 57 | def get_package_obj(self, agent_version: str, custom_version: str, os_type: str, packet_format: str, 58 | architecture: str, module: AnsibleModule): 59 | """ 60 | Queries the API to get the info about the agent package which maches the parameters 61 | 62 | :param agent_version: which version to search for 63 | :type agent_version: str 64 | :param custom_version: custom agent version if specified 65 | :type custom_version: str 66 | :param os_type: For which OS the package should fit 67 | :type os_type: str 68 | :param packet_format: the packet format 69 | :type packet_format: str 70 | :param architecture: The OS architecture 71 | :type architecture: str 72 | :param module: Ansible module for error handling 73 | :type module: AnsibleModule 74 | :return: Returns the found agent object 75 | :rtype: dict 76 | """ 77 | 78 | # Build query parameters dependend on the Modules input 79 | # Default parameters which are set always 80 | query_params = { 81 | 'platformTypes': os_type.lower(), 82 | 'sortOrder': 'desc', 83 | 'sortBy': 'version', 84 | 'fileExtension': f".{packet_format}" 85 | } 86 | 87 | if self.site_id is not None: 88 | query_params['siteIds'] = str(self.site_id) 89 | 90 | if agent_version == 'custom': 91 | query_params['version'] = custom_version 92 | elif agent_version == 'latest': 93 | query_params['status'] = 'ga' 94 | 95 | if os_type == 'Linux': 96 | # Use query parameter to do a free text search matching the 'fileName' field beacause S1 API does not 97 | # provide the information elementary. 'osArches' parameter applies only for windows 98 | if architecture == 'aarch64': 99 | query_params['query'] = 'SentinelAgent-aarch64' 100 | else: 101 | query_params['query'] = 'SentinelAgent_linux' 102 | else: 103 | query_params['packageType'] = 'AgentAndRanger' 104 | # osArches is only supported if you query windows packaes 105 | query_params['osArches'] = architecture.replace('_', ' ') 106 | 107 | # translate dictionary to URI argurments and build full query 108 | query_params_encoded = urlencode(query_params) 109 | api_query_agent_package = f"{self.api_endpoint_update_agent_packages}?{query_params_encoded}" 110 | 111 | response = self.api_call(module, api_query_agent_package) 112 | if response["pagination"]["totalItems"] > 0: 113 | return response["data"][0] 114 | 115 | module.fail_json(msg="Error: No agent package found in management console. Please check the given parameters.") 116 | -------------------------------------------------------------------------------- /plugins/module_utils/sentinelone/sentinelone_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright: (c) 2024, Marco Wester 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | from __future__ import (absolute_import, division, print_function) 6 | __metaclass__ = type 7 | 8 | from ansible.module_utils.basic import AnsibleModule 9 | import json 10 | import traceback 11 | import copy 12 | import time 13 | from ansible.module_utils.six.moves.urllib.parse import quote_plus 14 | import ansible.module_utils.six.moves.urllib.error as urllib_error 15 | 16 | from ansible.module_utils.urls import fetch_url 17 | 18 | lib_imp_errors = {'lib_imp_err': None} 19 | try: 20 | from deepdiff import DeepDiff 21 | lib_imp_errors['has_lib'] = True 22 | except ImportError: 23 | lib_imp_errors['has_lib'] = False 24 | lib_imp_errors['lib_imp_err'] = traceback.format_exc() 25 | 26 | 27 | class SentineloneBase: 28 | def __init__(self, module: AnsibleModule): 29 | """ 30 | Initialization of the base super class 31 | 32 | :param module: Requires the AnsibleModule Object for parsing the parameters 33 | :type module: AnsibleModule 34 | """ 35 | 36 | # Set URIs for API endpoints 37 | api_uri_groups = "/web/api/v2.1/groups" 38 | api_uri_filters = "/web/api/v2.1/filters" 39 | api_uri_exclusions = "/web/api/v2.1/exclusions" 40 | api_uri_sites = "/web/api/v2.1/sites" 41 | api_uri_accounts = "/web/api/v2.1/accounts" 42 | api_uri_config_overrides = "/web/api/v2.1/config-override" 43 | api_uri_upgrade_policy = "/web/api/v2.1/tasks-configuration" 44 | api_uri_update_agent_packages = "/web/api/v2.1/update/agent/packages" 45 | 46 | # Build full API endpoint from base console URL and API URI 47 | self.api_endpoint_groups = module.params["console_url"] + api_uri_groups 48 | self.api_endpoint_filters = module.params["console_url"] + api_uri_filters 49 | self.api_endpoint_exclusions = module.params["console_url"] + api_uri_exclusions 50 | self.api_endpoint_accounts = module.params["console_url"] + api_uri_accounts 51 | self.api_endpoint_sites = module.params["console_url"] + api_uri_sites 52 | self.api_endpoint_config_overrides = module.params["console_url"] + api_uri_config_overrides 53 | self.api_endpoint_upgrade_policy = module.params["console_url"] + api_uri_upgrade_policy 54 | self.api_endpoint_update_agent_packages = module.params["console_url"] + api_uri_update_agent_packages 55 | 56 | # Assign passed parameters to class variables 57 | self.token = module.params["token"] 58 | self.console_url = module.params["console_url"] 59 | self.site_name = module.params["site_name"] 60 | self.state = module.params.get("state", None) 61 | self.group_names = module.params.get("groups", []) 62 | 63 | # Get AccountID by name 64 | self.current_account = self.get_account_obj(module) 65 | self.account_id = self.current_account["id"] 66 | 67 | # Get site object and extract SiteID 68 | self.current_site = self.get_site(self.site_name, module) 69 | if self.current_site is None: 70 | self.site_id = None 71 | else: 72 | self.site_id = self.current_site["id"] 73 | 74 | # Get GroupIDs by Name 75 | if self.group_names: 76 | self.current_group_ids_names = self.get_group_ids_names(self.group_names, module) 77 | else: 78 | self.current_group_ids_names = [] 79 | 80 | self.module = module 81 | 82 | def api_call(self, module: AnsibleModule, api_endpoint: str, http_method: str = "get", parse_response: bool = True, 83 | **kwargs): 84 | """ 85 | Queries api_endpoint. if no http_method is passed a get request is performed. api_endpoint is mandatory 86 | 87 | 88 | :param module: Ansible module for error handling 89 | :type module: AnsibleModule 90 | :param api_endpoint: URL of the API endpoint to query 91 | :type api_endpoint: str 92 | :param http_method: HTTP query method. Default is GET but POST, PUT, DELETE, etc. is supported as well 93 | :type http_method: str 94 | :param kwargs: See below 95 | :param parse_response: Wether or not the response should be parsed as json 96 | :type parse_response: bool 97 | :Keyword Arguments: 98 | * *headers* (dict) -- 99 | You can pass custom headers or custom body. 100 | If custom headers is not set default vaules will apply and should be sufficient. 101 | * *body* (dict) -- 102 | If body is not passed body is empty 103 | * *error_msg* (str) -- 104 | Start of error message in case of a failed API call 105 | :return: Returnes parsed json response if parse_response is true. Type of return value depends on the data 106 | returned by the API. Usually dictionary. If parse_response is false the raw object will be returned 107 | :rtype: dict, HTTPResponse 108 | """ 109 | 110 | request_timeout = 120 111 | retries = 3 112 | retry_pause = 3 113 | 114 | headers = {} 115 | 116 | if not kwargs.get("headers", {}): 117 | headers['Accept'] = 'application/json' 118 | headers['Content-Type'] = 'application/json' 119 | headers['Authorization'] = f'APIToken {self.token}' 120 | 121 | body = kwargs.get("body", {}) 122 | 123 | error_msg = f'{kwargs.get("error_msg", "API call failed.")} API-Endpoint: {api_endpoint}' 124 | 125 | retry_count = 0 126 | try: 127 | while True: 128 | retry_count += 1 129 | try: 130 | if body: 131 | body_json = json.dumps(body) 132 | response_raw, response_info = fetch_url(module, api_endpoint, headers=headers, data=body_json, 133 | method=http_method, timeout=request_timeout) 134 | else: 135 | response_raw, response_info = fetch_url(module, api_endpoint, headers=headers, 136 | method=http_method, timeout=request_timeout) 137 | 138 | status_code = response_info['status'] 139 | if status_code == -1: 140 | # If the request runtime exceeds the timout 141 | module.exit_json(msg=f"{error_msg} Error: {response_info['msg']} after {request_timeout}s.") 142 | elif status_code >= 400: 143 | response_unparsed = response_info['body'].decode('utf-8') 144 | response = json.loads(response_unparsed) 145 | raise response_raw 146 | else: 147 | if parse_response: 148 | response = json.loads(response_raw.read().decode('utf-8')) 149 | else: 150 | response = response_raw 151 | break 152 | except Exception as err: 153 | if retry_count == retries: 154 | raise err 155 | 156 | time.sleep(retry_pause) 157 | except json.decoder.JSONDecodeError as err: 158 | module.fail_json(msg=f"API response is no valid JSON. Error: {str(err)}") 159 | except urllib_error.HTTPError as err: 160 | module.fail_json( 161 | msg=f"{error_msg} Status code: {err.code} {err.reason}. Error: {response_unparsed}") 162 | 163 | return response 164 | 165 | def get_account_obj(self, module: AnsibleModule): 166 | """ 167 | Returns the account obj 168 | 169 | :param module: Ansible module for error handling 170 | :type module: AnsibleModule 171 | :return: Account obj as dict 172 | :rtype: dict 173 | """ 174 | 175 | api_url = f"{self.api_endpoint_accounts}?states=active" 176 | error_msg = "Failed to get account" 177 | response = self.api_call(module, api_url, error_msg=error_msg) 178 | 179 | if response["pagination"]["totalItems"] == 1: 180 | return response["data"][0] 181 | elif response["pagination"]["totalItems"] > 1: 182 | module.fail_json(msg="Multiple Accounts found. This module only works with single-account " 183 | "management consoles") 184 | else: 185 | module.fail_json(msg="No Accounts found. This error should never appear") 186 | 187 | def get_site(self, site_name: str, module: AnsibleModule): 188 | """ 189 | Returns site object for given site_name 190 | 191 | :param site_name: Name of the site 192 | :type site_name: str 193 | :param module: Ansible module for error handling 194 | :type module: AnsibleModule 195 | :return: Site object as dict 196 | :rtype: dict 197 | """ 198 | 199 | if site_name is None: 200 | return None 201 | 202 | api_url = f"{self.api_endpoint_sites}?name={quote_plus(site_name)}&state=active" 203 | error_msg = "Failed to get site." 204 | response = self.api_call(module, api_url, error_msg=error_msg) 205 | 206 | if response["pagination"]["totalItems"] == 1: 207 | site_obj = response["data"]["sites"][0] 208 | elif self.__class__.__name__ == "SentineloneSite": 209 | return None 210 | else: 211 | module.fail_json(msg=f"Site {site_name} not found") 212 | return site_obj 213 | 214 | def get_group_ids_names(self, group_names: list, module: AnsibleModule): 215 | """ 216 | Returns group_ids_names for given group_names 217 | 218 | :param group_names: One or more group names 219 | :type group_names: list 220 | :param module: Ansible module for error handling 221 | :type module: AnsibleModule 222 | :return: List with tuples of group_id and group_name 223 | :rtype: list 224 | """ 225 | 226 | group_ids_names = [] 227 | for group_name in group_names: 228 | api_url = f"{self.api_endpoint_groups}?name={quote_plus(group_name)}&siteIds={quote_plus(self.site_id)}" 229 | error_msg = f"Failed to get group {group_name}." 230 | response = self.api_call(module, api_url, error_msg=error_msg) 231 | 232 | if response["pagination"]["totalItems"] == 1: 233 | group_id = response["data"][0]["id"] 234 | group_ids_names.append((group_id, group_name)) 235 | else: 236 | module.fail_json(msg=f"Group {group_name} not found") 237 | 238 | return group_ids_names 239 | 240 | def get_current_filter(self, filter_name: str, module: AnsibleModule): 241 | """ 242 | Returns the filter object of filter_name 243 | 244 | :param filter_name: Filter name 245 | :type filter_name: str 246 | :param module: Ansible module for error handling 247 | :type module: AnsibleModule 248 | :return: Filter object 249 | :rtype: dict 250 | """ 251 | 252 | api_url = f"{self.api_endpoint_filters}?siteIds={self.site_id}&query={quote_plus(filter_name)}" 253 | error_msg = "Failed to get filters from API." 254 | response = self.api_call(module, api_url, error_msg=error_msg) 255 | 256 | # API parameter "query" also matches substring. Making sure only the exactly matching element is returned 257 | filtered_response = list(filter(lambda filterobj: filterobj['name'] == filter_name, response['data'])) 258 | count_filters = len(filtered_response) 259 | 260 | if count_filters > 1: 261 | module.fail_json(msg=("Error in get_current_filter: filtered_response has more than one element. " 262 | "Should only contain zero or one element.")) 263 | elif count_filters == 1: 264 | return filtered_response[0] 265 | else: 266 | return {} 267 | 268 | def merge_compare(self, current_data: dict, desired_state_data: dict, exclude_path: list = None): 269 | """ 270 | Check if desired_state_data is already set in current_data. Therfore we are merging the two dictionaries. 271 | current_data is updated by desired_state_data. After the merging current_data is compared to merged_data. 272 | If no difference is found. No changes are needed. If there are differences the module needs to update the object 273 | 274 | :param current_data: Currently set settings 275 | :type current_data: dict 276 | :param desired_state_data: Settings we want to make sure they exist 277 | :type desired_state_data: dict 278 | :param exclude_path: Optional parameter. You can exclude some (nested) keys from comparison 279 | :type exclude_path: str 280 | :return: Returns a tuple of diff (DeepDiff object) and the merged_dict (dictionary object) 281 | :rtype: tuple 282 | """ 283 | 284 | if exclude_path is None: 285 | exclude_path = [] 286 | # Python does not create new objects by assigning one variable to another. It only copies the reference to the 287 | # same memory address (copies the pointer). You have to use copy oder deepcopy. Deepcopy creates new objects 288 | # of the nested objects as well 289 | merged_dict = copy.deepcopy(current_data) 290 | self.merge(merged_dict, desired_state_data) 291 | diff = DeepDiff(current_data, merged_dict, exclude_paths=exclude_path) 292 | return diff, merged_dict 293 | 294 | @staticmethod 295 | def compare(dict1: dict, dict2: dict, exclude_path: list = None): 296 | """ 297 | Compare two dictionaries 298 | 299 | :param dict1: First dict 300 | :type dict1: dict 301 | :param dict2: Second dict 302 | :type dict2: dict 303 | :param exclude_path: Optional parameter. You can exclude some (nested) keys from comparison 304 | :type exclude_path: str 305 | :return: DeepDiff object with the differences of the two dictioniaries 306 | :rtype: DeepDiff 307 | """ 308 | 309 | diff = DeepDiff(dict1, dict2, exclude_paths=exclude_path) 310 | 311 | return diff 312 | 313 | def merge(self, parent: dict, child: dict): 314 | """ 315 | Merges nested dictionaries as recursive method. It updates the parent dict with items from the child dict. 316 | No return is needed because it directly updates the variable which is passed for parent to the method 317 | 318 | :param parent: Parent dictionary which will be updated by child dictionary 319 | :type parent: dict 320 | :param child: Child dictionary which updates parent dictionary 321 | :type child: dict 322 | """ 323 | 324 | for key in child: 325 | if key in parent and isinstance(parent[key], dict) and isinstance(child[key], dict): 326 | self.merge(parent[key], child[key]) 327 | else: 328 | parent[key] = child[key] 329 | 330 | def remove_dict_from_dict(self, current_dict: dict, remove_dict: dict): 331 | """ 332 | Remove nested dictionary keys from current_dict if they exist in remove_dict. 333 | This method acts as follows: 334 | Case 1: If current_dict[key] and remove_dict[key] are dictionaries make recursion with subordinated dictionaries 335 | Case 2: If current_dict[key] and remove_dict[key] are not a dictionary remove the key from current_dict 336 | Case 3: If current_dict[key] is a dictionary and remove_dict[key] is not remove the key from current_dict 337 | Case 4: If current_dict[key] is not a dicitonary and remove_dict[key] is a dictionary do nothing 338 | 339 | :param current_dict: The dictionary from which the keys should be removed 340 | :type current_dict: dict 341 | :param remove_dict: The dictionary which should be removed from current_dict 342 | :type remove_dict: dict 343 | """ 344 | 345 | # Iterate over all keys which should get removed 346 | for key in remove_dict.keys(): 347 | # Check if key exists in current_dict. Check for None type necessary because value of key could be boolean 348 | if current_dict.get(key) is not None: 349 | # If current_dict[key] and remove_dict[key] are dictionaries make recursion with subordinated dictionary 350 | if isinstance(current_dict[key], dict) and isinstance(remove_dict[key], dict): 351 | self.remove_dict_from_dict(current_dict[key], remove_dict[key]) 352 | # If all keys of the subordinated dictionary are removed, remove the parent key as well 353 | if current_dict[key] in ('', None, {}): 354 | del current_dict[key] 355 | # If current_dict[key] is a dict and remove_dict[key] is not or current_dict[key] and remove_dict[key] 356 | # are no dictionaries remove the key. 357 | elif (isinstance(current_dict[key], dict) and not isinstance(remove_dict[key], dict)) or ( 358 | not isinstance(current_dict[key], dict) and not isinstance(remove_dict[key], dict)): 359 | del current_dict[key] 360 | -------------------------------------------------------------------------------- /plugins/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/plugins/modules/__init__.py -------------------------------------------------------------------------------- /plugins/modules/sentinelone_agent_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # Erik Schindler 6 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | DOCUMENTATION = r''' 11 | --- 12 | module: sentinelone_agent_info 13 | short_description: "Get info about the SentinelOne agent package" 14 | version_added: "2.0.0" 15 | description: 16 | - "This module is able to get info about the sentinelone agent package you requested" 17 | options: 18 | console_url: 19 | description: 20 | - "Insert your management console URL" 21 | type: str 22 | required: true 23 | site: 24 | description: 25 | - "Optional name of the site from where the agent package is located" 26 | - "If omitted the scope will be on account level" 27 | type: str 28 | required: false 29 | token: 30 | description: 31 | - "SentinelOne API auth token to authenticate at the management API" 32 | type: str 33 | required: true 34 | agent_version: 35 | description: 36 | - "Version of the agent to get info about" 37 | - "B(latest) (default) - Latest GA (stable) release for the specified parameters" 38 | - "B(latest_ea) - same as latest, but also includes EA packages" 39 | - "B(custom) - custom_version is required when agent_versioin is custom" 40 | type: str 41 | default: latest 42 | required: false 43 | choices: 44 | - latest 45 | - latest_ea 46 | - custom 47 | custom_version: 48 | description: 49 | - "Explicit version of the agent to get info about" 50 | - "Has to be set when agent_version=custom" 51 | - "Will be ignored if B(agent_version) is not B(custom)" 52 | type: str 53 | required: false 54 | os_type: 55 | description: 56 | - "The type of the OS" 57 | type: str 58 | required: true 59 | choices: 60 | - Linux 61 | - Windows 62 | packet_format: 63 | description: 64 | - "The format of the agent package" 65 | type: str 66 | required: true 67 | choices: 68 | - rpm 69 | - deb 70 | - msi 71 | - exe 72 | architecture: 73 | description: 74 | - "Architecture of the packet" 75 | - "Windows: Only B(32_bit) and B(64_bit) are allowed" 76 | - "Linux: If not set infos about the 64 bit agent will be retrieved. If set to B(aarch64) infos about the ARM agent will be retrieved" 77 | type: str 78 | required: false 79 | default: 64_bit 80 | choices: 81 | - 32_bit 82 | - 64_bit 83 | - aarch64 84 | author: 85 | - "Marco Wester (@mwester117) " 86 | requirements: 87 | - "deepdiff >= 5.6" 88 | notes: 89 | - "Python module deepdiff required. Tested with version >=5.6. Lower version may work too" 90 | - "Currently only supported in single-account management consoles" 91 | ''' 92 | 93 | EXAMPLES = r''' 94 | --- 95 | - name: Get info about specified package 96 | sva.sentinelone.sentinelone_agent_info: 97 | console_url: "https://XXXXX.sentinelone.net" 98 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 99 | os_type: "Windows" 100 | packet_format: "msi" 101 | architecture: "64_bit" 102 | agent_version: "latest" 103 | ''' 104 | 105 | RETURN = r''' 106 | --- 107 | original_message: 108 | description: Detailed infos about the requested agent package 109 | type: str 110 | returned: on success 111 | sample: >- 112 | {'accounts': [], 'createdAt': '2024-09-17T14:28:31.657142Z', 'fileExtension': '.rpm', 'fileName': 'SentinelAgent_linux_x86_64_v24_2_2_20.rpm', 113 | 'fileSize': 46269381, 'id': '2041405603323138037', 114 | 'link': 'https://XXXXX.sentinelone.net/web/api/v2.1/update/agent/download/2049999999991104/2041999999999999037', 'majorVersion': '24.2', 115 | 'minorVersion': 'GA', 'osArch': '32/64 bit', 'osType': 'linux', 'packageType': 'Agent', 'platformType': 'linux', 'rangerVersion': null, 116 | 'scopeLevel': 'global', 'sha1': '3d32d43860bc0a77926a4d8186c8427be59c1a06', 'sites': [], 'status': 'ga', 'supportedOsVersions': null, 117 | 'updatedAt': '2024-09-17T14:28:31.655927Z', 'version': '24.2.2.20'} 118 | message: 119 | description: Get basic infos about the agent package 120 | type: str 121 | returned: on success 122 | sample: "Agent found: SentinelAgent_linux_x86_64_v24_2_2_20.rpm" 123 | ''' 124 | 125 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 126 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_agent_base import SentineloneAgentBase 127 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import lib_imp_errors 128 | 129 | 130 | class SentineloneAgentInfo(SentineloneAgentBase): 131 | def __init__(self, module: AnsibleModule): 132 | """ 133 | Initialization of the AgentInfo object 134 | 135 | :param module: Requires the AnsibleModule Object for parsing the parameters 136 | :type module: AnsibleModule 137 | """ 138 | 139 | # super Class 140 | super().__init__(module) 141 | 142 | 143 | def run_module(): 144 | # define available arguments/parameters a user can pass to the module 145 | module_args = dict( 146 | console_url=dict(type='str', required=True), 147 | site=dict(type='str', required=False), 148 | token=dict(type='str', required=True, no_log=True), 149 | agent_version=dict(type='str', required=False, default='latest', choices=['latest', 'latest_ea', 'custom']), 150 | custom_version=dict(type='str', required=False), 151 | os_type=dict(type='str', required=True, choices=['Linux', 'Windows']), 152 | packet_format=dict(type='str', required=True, choices=['rpm', 'deb', 'msi', 'exe']), 153 | architecture=dict(type='str', required=False, choices=['32_bit', '64_bit', 'aarch64'], default="64_bit") 154 | ) 155 | 156 | module = AnsibleModule( 157 | argument_spec=module_args, 158 | required_if=[ 159 | ('agent_version', 'custom', ('custom_version',)) 160 | ], 161 | supports_check_mode=True 162 | ) 163 | 164 | if not lib_imp_errors['has_lib']: 165 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 166 | 167 | # Create AgentInfo Object 168 | agent_info_obj = SentineloneAgentInfo(module) 169 | 170 | agent_version = agent_info_obj.agent_version 171 | custom_version = agent_info_obj.custom_version 172 | os_type = agent_info_obj.os_type 173 | packet_format = agent_info_obj.packet_format 174 | architecture = agent_info_obj.architecture 175 | 176 | # Get package object from API with given parameters 177 | package_obj = agent_info_obj.get_package_obj(agent_version, custom_version, os_type, packet_format, architecture, module) 178 | 179 | changed = False 180 | original_message = package_obj 181 | basic_message = f"Agent found: {package_obj['fileName']}" 182 | 183 | result = dict( 184 | changed=changed, 185 | original_message=original_message, 186 | message=basic_message 187 | ) 188 | 189 | # in the event of a successful module execution, you will want to 190 | # simple AnsibleModule.exit_json(), passing the key/value results 191 | module.exit_json(**result) 192 | 193 | 194 | def main(): 195 | run_module() 196 | 197 | 198 | if __name__ == '__main__': 199 | main() 200 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_download_agent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # Erik Schindler 6 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 7 | from __future__ import (absolute_import, division, print_function) 8 | __metaclass__ = type 9 | 10 | DOCUMENTATION = r''' 11 | --- 12 | module: sentinelone_download_agent 13 | short_description: "Download SentinelOne agent from Management Console" 14 | version_added: "1.1.0" 15 | description: 16 | - "This module is able to download a SentinelOne agent from Management Console" 17 | options: 18 | console_url: 19 | description: 20 | - "Insert your management console URL" 21 | type: str 22 | required: true 23 | site: 24 | description: 25 | - "Name of the site from which to download the agent" 26 | - "If omitted the scope will be on account level" 27 | type: str 28 | required: false 29 | token: 30 | description: 31 | - "SentinelOne API auth token to authenticate at the management API" 32 | type: str 33 | required: true 34 | agent_version: 35 | description: 36 | - "Version of the agent to be downloaded." 37 | - "B(latest) (default) - download latest GA (stable) release for the specified parameters" 38 | - "B(latest_ea) - same as latest, but also includes EA packages" 39 | - "B(custom) - custom_version is required when agent_versioin is custom" 40 | type: str 41 | default: latest 42 | required: false 43 | choices: 44 | - latest 45 | - latest_ea 46 | - custom 47 | custom_version: 48 | description: 49 | - "Explicit version of the file to be downloaded" 50 | - "Has to be set when agent_version=custom" 51 | - "Will be ignored if B(agent_version) is not B(custom)" 52 | type: str 53 | required: false 54 | os_type: 55 | description: 56 | - "The type of the OS for which the agent should be downloaded" 57 | type: str 58 | required: true 59 | choices: 60 | - Linux 61 | - Windows 62 | packet_format: 63 | description: 64 | - "The format of the packet which should be downloaded" 65 | type: str 66 | required: true 67 | choices: 68 | - rpm 69 | - deb 70 | - msi 71 | - exe 72 | architecture: 73 | description: 74 | - "Architecture of the packet which should be downloaded" 75 | - "Windows: Only B(32_bit) and B(64_bit) are allowed" 76 | - "Linux: If not set 64 bit agent will be downloaded. If set to B(aarch64) the ARM agent will be downloaded" 77 | type: str 78 | required: false 79 | default: 64_bit 80 | choices: 81 | - 32_bit 82 | - 64_bit 83 | - aarch64 84 | download_dir: 85 | description: 86 | - "Set the path where the agent should be downloaded." 87 | - "If not set the agent will be downloaded to the working directory." 88 | - "If the directory does not exists it will be created" 89 | type: str 90 | required: false 91 | default: ./ 92 | author: 93 | - "Marco Wester (@mwester117) " 94 | - "Erik Schindler (@mintalicious) " 95 | requirements: 96 | - "deepdiff >= 5.6" 97 | notes: 98 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 99 | - "Currently only supported in single-account management consoles" 100 | ''' 101 | 102 | EXAMPLES = r''' 103 | --- 104 | - name: Download latest agent for linux 105 | sva.sentinelone.sentinelone_download_agent: 106 | console_url: "https://XXXXX.sentinelone.net" 107 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 108 | os_type: "Linux" 109 | packet_format: "rpm" 110 | download_path: "/tmp" 111 | architecture: "64_bit" 112 | - name: Download latest agent for linux and include EA packages 113 | sva.sentinelone.sentinelone_download_agent: 114 | console_url: "https://XXXXX.sentinelone.net" 115 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 116 | os_type: "Linux" 117 | packet_format: "rpm" 118 | download_path: "/tmp" 119 | architecture: "64_bit" 120 | agent_version: "latest_ea" 121 | - name: Download specific agent version 122 | sva.sentinelone.sentinelone_download_agent: 123 | console_url: "https://XXXXX.sentinelone.net" 124 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 125 | os_type: "Windows" 126 | packet_format: "msi" 127 | architecture: "64_bit" 128 | agent_version: "custom" 129 | custom_version: "23.2.3.358" 130 | ''' 131 | 132 | RETURN = r''' 133 | --- 134 | original_message: 135 | description: Get detailed infos about the downloaded package 136 | type: str 137 | returned: on success 138 | sample: >- 139 | {'download_path': './', 'filename': 'SentinelInstaller_windows_64bit_v23_2_3_358.msi', 140 | 'full_path': './SentinelInstaller_windows_64bit_v23_2_3_358.msi'} 141 | message: 142 | description: Get basic infos about the downloaded package in an human readable format 143 | type: str 144 | returned: on success 145 | sample: Downloaded file SentinelInstaller_windows_64bit_v23_2_3_358.msi to ./ 146 | ''' 147 | 148 | from os import path, makedirs, remove 149 | 150 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 151 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_agent_base import SentineloneAgentBase 152 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import lib_imp_errors 153 | 154 | 155 | class SentineloneDownloadAgent(SentineloneAgentBase): 156 | def __init__(self, module: AnsibleModule): 157 | """ 158 | Initialization of the DownloadAgent object 159 | 160 | :param module: Requires the AnsibleModule Object for parsing the parameters 161 | :type module: AnsibleModule 162 | """ 163 | 164 | # super Class 165 | super().__init__(module) 166 | 167 | 168 | def run_module(): 169 | # define available arguments/parameters a user can pass to the module 170 | module_args = dict( 171 | console_url=dict(type='str', required=True), 172 | site=dict(type='str', required=False), 173 | token=dict(type='str', required=True, no_log=True), 174 | agent_version=dict(type='str', required=False, default='latest', choices=['latest', 'latest_ea', 'custom']), 175 | custom_version=dict(type='str', required=False), 176 | os_type=dict(type='str', required=True, choices=['Linux', 'Windows']), 177 | packet_format=dict(type='str', required=True, choices=['rpm', 'deb', 'msi', 'exe']), 178 | architecture=dict(type='str', required=False, choices=['32_bit', '64_bit', 'aarch64'], default="64_bit"), 179 | download_dir=dict(type='str', required=False, default='./') 180 | ) 181 | 182 | module = AnsibleModule( 183 | argument_spec=module_args, 184 | required_if=[ 185 | ('agent_version', 'custom', ('custom_version',)) 186 | ], 187 | supports_check_mode=False 188 | ) 189 | 190 | if not lib_imp_errors['has_lib']: 191 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 192 | 193 | # Create DownloadAgent Object 194 | download_agent_obj = SentineloneDownloadAgent(module) 195 | 196 | agent_version = download_agent_obj.agent_version 197 | custom_version = download_agent_obj.custom_version 198 | os_type = download_agent_obj.os_type 199 | packet_format = download_agent_obj.packet_format 200 | architecture = download_agent_obj.architecture 201 | 202 | # Get package object from API with given parameters 203 | package_obj = download_agent_obj.get_package_obj(agent_version, custom_version, os_type, packet_format, architecture, module) 204 | 205 | changed = False 206 | download_dir = download_agent_obj.download_dir 207 | url = package_obj['link'] 208 | filename = package_obj['fileName'] 209 | sha1_expected = package_obj['sha1'] 210 | filepath = f"{download_dir.rstrip('/')}/{filename}" 211 | 212 | if path.exists(filepath): 213 | basic_message = f"File {filename} already exists in {download_dir} - nothing to do." 214 | else: 215 | # Ensure download_dir exists and is a directory 216 | dest_is_dir = path.isdir(download_dir) 217 | if not dest_is_dir: 218 | if path.exists(download_dir): 219 | module.fail_json(msg=f"{download_dir} is a file but should be a directory.") 220 | else: 221 | makedirs(download_dir) 222 | 223 | result = download_agent_obj.api_call(module, url, parse_response=False) 224 | 225 | with open(filepath, 'wb') as file: 226 | file.write(result.read()) 227 | 228 | # Check SHA1 checksum 229 | sha1_file = module.sha1(filepath) 230 | if sha1_file != sha1_expected: 231 | remove(filepath) 232 | module.fail_json(msg="Download failed. SHA1 checksum mismatch. Deleted broken file.") 233 | 234 | changed = True 235 | basic_message = f"Downloaded file {filename} to {download_dir}" 236 | original_message = {'download_dir': download_dir, 'filename': filename, 'full_path': filepath} 237 | 238 | result = dict( 239 | changed=changed, 240 | original_message=original_message, 241 | message=basic_message 242 | ) 243 | 244 | # in the event of a successful module execution, you will want to 245 | # simple AnsibleModule.exit_json(), passing the key/value results 246 | module.exit_json(**result) 247 | 248 | 249 | def main(): 250 | run_module() 251 | 252 | 253 | if __name__ == '__main__': 254 | main() 255 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_filters.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | DOCUMENTATION = r''' 10 | --- 11 | module: sentinelone_filters 12 | short_description: "Manage SentinelOne Filters" 13 | version_added: "1.0.0" 14 | description: 15 | - "This module is able to create, update and delete filters in SentinelOne" 16 | options: 17 | console_url: 18 | description: 19 | - "Insert your management console URL" 20 | type: str 21 | required: true 22 | token: 23 | description: 24 | - "SentinelOne API auth token to authenticate at the management API" 25 | type: str 26 | required: true 27 | state: 28 | description: 29 | - "Select the I(state) of the filter" 30 | type: str 31 | default: present 32 | required: false 33 | choices: 34 | - present 35 | - absent 36 | site_name: 37 | description: 38 | - "Name of the site in SentinelOne" 39 | type: str 40 | required: true 41 | name: 42 | description: 43 | - "The name of the filter" 44 | type: str 45 | required: true 46 | filter_fields: 47 | description: 48 | - "Set the filter options you want to set. Available options can be referred in API documentation" 49 | - "e.g. computerName__contains or osTypes" 50 | type: dict 51 | required: false 52 | author: 53 | - "Marco Wester (@mwester117) " 54 | requirements: 55 | - "deepdiff >= 5.6" 56 | notes: 57 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 58 | - "Currently only supported in single-account management consoles" 59 | - "Currently not applicable for account level filters" 60 | ''' 61 | 62 | EXAMPLES = r''' 63 | --- 64 | - name: Create filter 65 | sva.sentinelone.sentinelone_filters: 66 | console_url: "https://XXXXX.sentinelone.net" 67 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 68 | site_name: "test" 69 | name: "MyFilter" 70 | filter_fields: 71 | computerName__contains: 72 | - MyComputerName 73 | osTypes: 74 | - windows 75 | - name: Update filter 76 | sva.sentinelone.sentinelone_filters: 77 | state: "present" 78 | console_url: "https://XXXXX.sentinelone.net" 79 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 80 | site_name: "test" 81 | name: "MyFilter" 82 | filter_fields: 83 | computerName__contains: 84 | - MyComputerName 85 | - MyOtherComputerName 86 | osTypes: 87 | - windows 88 | - name: Delete filter 89 | sva.sentinelone.sentinelone_filters: 90 | state: "absent" 91 | console_url: "https://XXXXX.sentinelone.net" 92 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 93 | site_name: "test" 94 | name: "MyFilter" 95 | ''' 96 | 97 | RETURN = r''' 98 | --- 99 | original_message: 100 | description: Get detailed infos about the changes made 101 | type: str 102 | returned: on success 103 | sample: {"changes": {"iterable_item_added": {"root['computerName__contains'][1]": "test123"}}, "siteName": "msd"} 104 | message: 105 | description: Get basic infos about the changes made 106 | type: str 107 | returned: on success 108 | sample: Filter is missing in site. Adding filter. 109 | ''' 110 | 111 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 112 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase, lib_imp_errors 113 | 114 | 115 | class SentineloneFilter(SentineloneBase): 116 | def __init__(self, module: AnsibleModule): 117 | """ 118 | Initialization of the filter object 119 | 120 | :param module: Requires the AnsibleModule Object for parsing the parameters 121 | :type module: AnsibleModule 122 | """ 123 | 124 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 125 | # super Class 126 | super().__init__(module) 127 | 128 | # Set module specific parameters 129 | self.filter_name = module.params["name"] 130 | self.desired_state_filter_fields = module.params["filter_fields"] 131 | 132 | self.current_filter = self.get_current_filter(self.filter_name, module) 133 | self.current_filter_id = self.current_filter.get('id', '') 134 | 135 | def get_update_body(self): 136 | """ 137 | Create body for update filter API request 138 | 139 | :return: Body for update filter API request 140 | :rtype: dict 141 | """ 142 | 143 | desired_state_filter_body = { 144 | "data": { 145 | "filterFields": self.desired_state_filter_fields, 146 | "name": self.filter_name 147 | } 148 | } 149 | 150 | return desired_state_filter_body 151 | 152 | def get_create_body(self): 153 | """ 154 | Create body for create filter API request 155 | 156 | :return: Body for create filter API request 157 | :rtype: dict 158 | """ 159 | 160 | desired_state_filter_body = { 161 | "filter": { 162 | "siteIds": [self.site_id] 163 | }, 164 | "data": { 165 | "filterFields": self.desired_state_filter_fields, 166 | "scopeLevel": "site", 167 | "siteId": self.site_id, 168 | "name": self.filter_name 169 | } 170 | } 171 | 172 | return desired_state_filter_body 173 | 174 | def create_filter(self, module: AnsibleModule): 175 | """ 176 | API call to create the filter 177 | 178 | :param module: Ansible module for error handling 179 | :type module: AnsibleModule 180 | :return: API response 181 | :rtype: dict 182 | """ 183 | 184 | api_url = self.api_endpoint_filters 185 | create_body = self.get_create_body() 186 | error_msg = "Failed to create filter." 187 | response = self.api_call(module, api_url, "POST", body=create_body, error_msg=error_msg) 188 | 189 | if not response['data']: 190 | module.fail_json(msg=("Error in create_filter: filter should have been created via API " 191 | "but API result was empty")) 192 | 193 | return response 194 | 195 | def delete_filter(self, module: AnsibleModule): 196 | """ 197 | API call to delete the filter 198 | 199 | :param module: Ansible module for error handling 200 | :type module: AnsibleModule 201 | :return: API response 202 | :rtype: dict 203 | """ 204 | 205 | api_url = f"{self.api_endpoint_filters}/{self.current_filter_id}" 206 | error_msg = "Failed to delete filter." 207 | response = self.api_call(module, api_url, "DELETE", error_msg=error_msg) 208 | 209 | if not response['data']['success']: 210 | module.fail_json(msg=("Error in delete_filter: Filter should have been deleted via API " 211 | "but API result was not 'success'")) 212 | 213 | return response 214 | 215 | def update_filter(self, module: AnsibleModule): 216 | """ 217 | API call to update the filter 218 | 219 | :param module: Ansible module for error handling 220 | :type module: AnsibleModule 221 | :return: API response 222 | :rtype: dict 223 | """ 224 | 225 | api_url = f"{self.api_endpoint_filters}/{self.current_filter_id}" 226 | update_body = self.get_update_body() 227 | error_msg = "Failed to update filter." 228 | response = self.api_call(module, api_url, "PUT", body=update_body, error_msg=error_msg) 229 | 230 | if not response['data']: 231 | module.fail_json(msg=("Error in update_filter: Filter should have been updated via API " 232 | "but API result was empty")) 233 | 234 | return response 235 | 236 | 237 | def run_module(): 238 | # define available arguments/parameters a user can pass to the module 239 | module_args = dict( 240 | console_url=dict(type='str', required=True), 241 | token=dict(type='str', required=True, no_log=True), 242 | state=dict(type='str', required=False, default='present', choices=['present', 'absent']), 243 | site_name=dict(type='str', required=True), 244 | name=dict(type='str', required=True), 245 | filter_fields=dict(type='dict', required=False), 246 | ) 247 | 248 | module = AnsibleModule( 249 | argument_spec=module_args, 250 | required_if=[ 251 | ('state', 'present', ('filter_fields',)) 252 | ], 253 | supports_check_mode=False 254 | ) 255 | 256 | if not lib_imp_errors['has_lib']: 257 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 258 | 259 | # Create filter Object 260 | filter_obj = SentineloneFilter(module) 261 | 262 | current_filter = filter_obj.current_filter 263 | desired_state_filter_fields = filter_obj.desired_state_filter_fields 264 | site_name = filter_obj.site_name 265 | state = filter_obj.state 266 | 267 | diffs = '' 268 | basic_message = '' 269 | if state == 'present': 270 | if current_filter: 271 | # If filter exists, check if it is up-to-date 272 | current_filter_fields = current_filter['filterFields'] 273 | diff = filter_obj.merge_compare(current_filter_fields, desired_state_filter_fields)[0] 274 | if diff: 275 | # Update filter if it is not up-to-date 276 | diffs = {'changes': dict(diff), 'siteName': site_name} 277 | basic_message = f'Filter exists in site {site_name} but is not up-to-date. Updating Filter.' 278 | filter_obj.update_filter(module) 279 | else: 280 | # Creates the filter if it is missing 281 | basic_message = f'Filter is missing in site {site_name}. Adding filter' 282 | diffs = {'changes': basic_message} 283 | filter_obj.create_filter(module) 284 | 285 | else: 286 | # Filter should be deleted 287 | if current_filter: 288 | # Check if it exists 289 | basic_message = f'Filter exists in site {site_name}. Deleting filter' 290 | diffs = {'changes': basic_message} 291 | filter_obj.delete_filter(module) 292 | 293 | result = dict( 294 | changed=False, 295 | original_message=diffs, 296 | message=basic_message 297 | ) 298 | 299 | # If we made changes to the objects the list diffs is not empty. 300 | # So we can use it to update result['changes'] to True if necessary 301 | if diffs: 302 | result['changed'] = True 303 | 304 | # in the event of a successful module execution, you will want to 305 | # simple AnsibleModule.exit_json(), passing the key/value results 306 | module.exit_json(**result) 307 | 308 | 309 | def main(): 310 | run_module() 311 | 312 | 313 | if __name__ == '__main__': 314 | main() 315 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_groups.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | DOCUMENTATION = ''' 10 | --- 11 | module: sentinelone_groups 12 | short_description: "Manage SentinelOne Groups" 13 | version_added: "1.0.0" 14 | description: 15 | - "This module is able to create, update and delete static and dynamic groups in SentinelOne" 16 | options: 17 | console_url: 18 | description: 19 | - "Insert your management console URL" 20 | type: str 21 | required: true 22 | token: 23 | description: 24 | - "SentinelOne API auth token to authenticate at the management API" 25 | type: str 26 | required: true 27 | state: 28 | description: 29 | - "Select the I(state) of the group" 30 | type: str 31 | default: present 32 | required: false 33 | choices: 34 | - present 35 | - absent 36 | site_name: 37 | description: 38 | - "Name of the site in SentinelOne" 39 | type: str 40 | required: true 41 | name: 42 | description: 43 | - "Name of the group or groups to create. You can pass multiple groups as a list" 44 | type: list 45 | elements: str 46 | required: true 47 | filter_name: 48 | description: 49 | - "If set this module creates a dynamic group based on the passed filter_name. If not set a static group is created" 50 | - "Can only be used if you create a single group." 51 | type: str 52 | required: false 53 | default: "" 54 | author: 55 | - "Marco Wester (@mwester117) " 56 | requirements: 57 | - "deepdiff >= 5.6" 58 | notes: 59 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 60 | - "Currently only supported in single-account management consoles" 61 | - "Can not convert from static to dynamic group or vice versa" 62 | - "Always inherits policy from site level. To change the policy please use the sentinelone_policy module." 63 | ''' 64 | 65 | EXAMPLES = r''' 66 | --- 67 | - name: Create single static group 68 | sva.sentinelone.sentinelone_groups: 69 | state: "present" 70 | console_url: "https://XXXXX.sentinelone.net" 71 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 72 | site_name: "test" 73 | name: "MyGroup" 74 | 75 | - name: Create single dynamic group 76 | sva.sentinelone.sentinelone_groups: 77 | state: "present" 78 | console_url: "https://XXXXX.sentinelone.net" 79 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 80 | site_name: "test" 81 | name: "MyGroup" 82 | filter_name: "MyFilter" 83 | 84 | - name: Create multiple static groups 85 | sva.sentinelone.sentinelone_groups: 86 | state: "present" 87 | console_url: "https://XXXXX.sentinelone.net" 88 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 89 | site_name: "test" 90 | name: 91 | - "MyGroup1" 92 | - "MyGroup2" 93 | - "MyGroup3" 94 | 95 | - name: Delete single static/dynamic group 96 | sva.sentinelone.sentinelone_groups: 97 | state: "absent" 98 | console_url: "https://XXXXX.sentinelone.net" 99 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 100 | site_name: "test" 101 | name: "MyGroup" 102 | 103 | - name: Delete multiple static/dynamic groups 104 | sva.sentinelone.sentinelone_groups: 105 | state: "absent" 106 | console_url: "https://XXXXX.sentinelone.net" 107 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 108 | site_name: "test" 109 | name: 110 | - "MyGroup1" 111 | - "MyGroup2" 112 | - "MyGroup3" 113 | ''' 114 | 115 | RETURN = r''' 116 | --- 117 | original_message: 118 | description: Get detailed infos about the changes made 119 | type: str 120 | returned: on success 121 | sample: [{"changes": {"values_changed": {"root['filterId']": 122 | {"new_value": "999999999999999999", "old_value": "888888888888888888"}}}, "groupName": "test123"}] 123 | message: 124 | description: Get basic infos about the changes made 125 | type: list 126 | returned: on success 127 | sample: ["Group test123 created."] 128 | ''' 129 | 130 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 131 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase, lib_imp_errors 132 | from ansible.module_utils.six.moves.urllib.parse import quote_plus 133 | 134 | 135 | class SentineloneGroups(SentineloneBase): 136 | def __init__(self, module: AnsibleModule): 137 | """ 138 | Initialization of the groups object 139 | 140 | :param module: Requires the AnsibleModule Object for parsing the parameters 141 | :type module: AnsibleModule 142 | """ 143 | 144 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 145 | # super Class 146 | super().__init__(module) 147 | 148 | # Set module specific parameters 149 | self.group_names = module.params["name"] 150 | self.filter_name = module.params["filter_name"] 151 | 152 | # Do sanity checks 153 | self.check_sanity(self.state, self.group_names, self.filter_name, module) 154 | 155 | self.current_groups = self.get_groups(self.group_names, module) 156 | if self.filter_name: 157 | # check if given filter for dynamic group exists 158 | self.filter_obj = self.get_current_filter(self.filter_name, module) 159 | if not self.filter_obj: 160 | module.fail_json(msg=f"Error: Filter {self.filter_name} does not exist.") 161 | self.filter_id = self.filter_obj['id'] 162 | 163 | def get_groups(self, group_names: list, module: AnsibleModule): 164 | """ 165 | API call to get the existing group objects 166 | 167 | :param group_names: name of the groups to get 168 | :type group_names: list 169 | :param module: Ansible module for error handling 170 | :type module: AnsibleModule 171 | :return: List of group objects 172 | :rtype: list 173 | """ 174 | 175 | current_groups = [] 176 | for group_name in group_names: 177 | api_url = self.api_endpoint_groups + (f"?siteIds={self.site_id}&" 178 | f"name={quote_plus(group_name)}") 179 | error_msg = f"Failed to query group {group_name} from API" 180 | response = self.api_call(module, api_url, error_msg=error_msg) 181 | 182 | if response['pagination']['totalItems'] > 0: 183 | current_groups.append(response['data'][0]) 184 | 185 | return current_groups 186 | 187 | def get_desired_state_group_body(self, group_name: str): 188 | """ 189 | Create body 190 | 191 | :param group_name: Name of the group 192 | :type group_name: str 193 | :return: Group body object 194 | :rtype: dict 195 | """ 196 | desired_state_groups = { 197 | "data": { 198 | "inherits": True, 199 | "siteId": self.site_id, 200 | "name": group_name 201 | } 202 | } 203 | 204 | if self.filter_name: 205 | # if we are creating a dynamic group add the filter id to body 206 | desired_state_groups['data']['filterId'] = self.filter_id 207 | 208 | return desired_state_groups 209 | 210 | def create_group(self, create_body: dict, error_msg: str, module: AnsibleModule): 211 | """ 212 | API call to create a group 213 | 214 | :param create_body: Body for the create query 215 | :type create_body: dict 216 | :param error_msg: Message used if an API request failes 217 | :type error_msg: str 218 | :param module: Ansible module for error handling 219 | :type module: AnsibleModule 220 | :return: create query response object 221 | :rtype: dict 222 | """ 223 | 224 | api_url = self.api_endpoint_groups 225 | response = self.api_call(module, api_url, "POST", body=create_body, error_msg=error_msg) 226 | 227 | if response['data']['name'] != create_body['data']['name']: 228 | module.fail_json(msg=(f"Group {create_body['data']['name']} should be created via API but " 229 | f"result was empty")) 230 | 231 | return response 232 | 233 | def update_group(self, update_body: dict, groupid: str, error_msg: str, module: AnsibleModule): 234 | """ 235 | API call to update a group 236 | 237 | :param update_body: Body for the update query 238 | :type update_body: dict 239 | :param groupid: ID of the group which should be updated 240 | :type groupid: str 241 | :param error_msg: Message used if an API request failes 242 | :type error_msg: str 243 | :param module: Ansible module for error handling 244 | :type module: AnsibleModule 245 | :return: API response of the update query 246 | :rtype: dict 247 | """ 248 | 249 | api_url = f"{self.api_endpoint_groups}/{groupid}" 250 | response = self.api_call(module, api_url, "PUT", body=update_body, error_msg=error_msg) 251 | 252 | if response['data']['name'] != update_body['data']['name']: 253 | module.fail_json(msg=(f"Group {update_body['data']['name']} should have been updated via API" 254 | f"but API result was empty")) 255 | 256 | return response 257 | 258 | def delete_group(self, group_id: str, error_msg: str, module: AnsibleModule): 259 | """ 260 | API call to delete a group 261 | 262 | :param group_id: ID of the group which should be deleted 263 | :type group_id: str 264 | :type error_msg: Message used if an API request failes 265 | :type error_msg: str 266 | :param module: Ansible module for error handling 267 | :type module: AnsibleModule 268 | :return: API response of the delete-query 269 | :rtype: dict 270 | """ 271 | 272 | api_url = f"{self.api_endpoint_groups}/{group_id}" 273 | response = self.api_call(module, api_url, "DELETE", error_msg=error_msg) 274 | 275 | if not response['data']['success']: 276 | module.fail_json(msg="Group should have been deleted via API but API result was empty") 277 | 278 | return response 279 | 280 | @staticmethod 281 | def check_sanity(state: str, group_names: list, filter_name: str, module: AnsibleModule): 282 | """ 283 | Check if the passed module arguments are contradicting each other 284 | 285 | :param state: Present or absent 286 | :type state: str 287 | :param group_names: List of the group names which should be created or deleted 288 | :type group_names: list 289 | :param filter_name: Name of the optional filter for creating a dynamic group 290 | :type filter_name: str 291 | :param module: Ansible module for error handling 292 | :type module: AnsibleModule 293 | """ 294 | 295 | if state == 'present' and len(group_names) > 1 and filter_name: 296 | module.fail_json(msg=("Error: You passed multiple groups to the module while creating a dynamic group. " 297 | "Please either remove filter_name or pass only one group.")) 298 | 299 | 300 | def run_module(): 301 | # define available arguments/parameters a user can pass to the module 302 | module_args = dict( 303 | console_url=dict(type='str', required=True), 304 | token=dict(type='str', required=True, no_log=True), 305 | state=dict(type='str', required=False, default='present', choices=['present', 'absent']), 306 | site_name=dict(type='str', required=True), 307 | name=dict(type='list', required=True, elements='str'), 308 | filter_name=dict(type='str', required=False, default=""), 309 | ) 310 | 311 | module = AnsibleModule( 312 | argument_spec=module_args, 313 | supports_check_mode=False 314 | ) 315 | 316 | if not lib_imp_errors['has_lib']: 317 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 318 | 319 | # Create exclusion Object 320 | groups_obj = SentineloneGroups(module) 321 | 322 | # List with all found groups. 323 | current_groups = groups_obj.current_groups 324 | group_names = groups_obj.group_names 325 | state = groups_obj.state 326 | 327 | diffs = [] 328 | basic_message = [] 329 | 330 | if state == 'present': 331 | for group_name in group_names: 332 | # Check if group exists 333 | desired_state_group = groups_obj.get_desired_state_group_body(group_name) 334 | current_group = list(filter(lambda filterobj: filterobj['name'] == group_name, current_groups)) 335 | if current_group: 336 | # Group exists. Check if it differs from desired state, ignoring inhertiance property. 337 | diff = groups_obj.merge_compare(current_group[0], desired_state_group['data'], ["root['inherits']"])[0] 338 | # Check if diff exists and do not want to convert dynamic to static group and vice versa 339 | if diff: 340 | if ((current_group[0]['type'] == 'dynamic' and module.params['filter_name']) or 341 | (current_group[0]['type'] == 'static' and not module.params['filter_name'])): 342 | group_id = current_group[0]['id'] 343 | # Inheritance and policy should not be maintained by this module. 344 | # Removing the key because if not the module will update the inherintance property 345 | del desired_state_group['data']['inherits'] 346 | error_msg = f"Failed to update group {group_name}." 347 | groups_obj.update_group(desired_state_group, group_id, error_msg, module) 348 | basic_message.append(f"Group {group_name} updated") 349 | diffs.append({'changes': dict(diff), 'groupName': group_name}) 350 | else: 351 | basic_message.append("Can not convert dynamic to static group and vice versa. Nothing changed.") 352 | else: 353 | # Group does not exist. Creating the group 354 | error_msg = f"Failed to create group {group_name}." 355 | groups_obj.create_group(desired_state_group, error_msg, module) 356 | basic_message.append(f"Group {group_name} created.") 357 | diffs.append({'changes': "Group created", 'groupName': group_name}) 358 | else: 359 | # state is set to absent. Removing the group if exist 360 | for group_name in group_names: 361 | # check if group exits 362 | current_group = list(filter(lambda filterobj: filterobj['name'] == group_name, current_groups)) 363 | if current_group: 364 | # if group exists delete it 365 | error_msg = f"Failed to delete group {group_name}." 366 | group_id = current_group[0]['id'] 367 | groups_obj.delete_group(group_id, error_msg, module) 368 | basic_message.append(f"Group {group_name} deleted.") 369 | diffs.append({'changes': 'Group deleted', 'groupName': group_name}) 370 | 371 | result = dict( 372 | changed=False, 373 | original_message=diffs, 374 | message=basic_message 375 | ) 376 | 377 | # If we made changes to the objects the list diffs is not empty. 378 | # So we can use it to update result['changes'] to True if necessary 379 | if diffs: 380 | result['changed'] = True 381 | 382 | # in the event of a successful module execution, you will want to 383 | # simple AnsibleModule.exit_json(), passing the key/value results 384 | module.exit_json(**result) 385 | 386 | 387 | def main(): 388 | run_module() 389 | 390 | 391 | if __name__ == '__main__': 392 | main() 393 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_path_exclusions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester , Lasse Wackers 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | DOCUMENTATION = ''' 10 | --- 11 | module: sentinelone_path_exclusions 12 | short_description: "Manage SentinelOne Path Exclusions" 13 | version_added: "1.0.0" 14 | description: 15 | - "This module is able to create, update and delete path exclusions in SentinelOne" 16 | options: 17 | console_url: 18 | description: 19 | - "Insert your management console URL" 20 | type: str 21 | required: true 22 | token: 23 | description: 24 | - "SentinelOne API auth token to authenticate at the management API" 25 | type: str 26 | required: true 27 | state: 28 | description: 29 | - "Select the I(state) of exclusion" 30 | type: str 31 | default: present 32 | required: false 33 | choices: 34 | - present 35 | - absent 36 | site_name: 37 | description: 38 | - "Name of the site in SentinelOne" 39 | type: str 40 | required: true 41 | groups: 42 | description: 43 | - "Set this option to set the scope to group level" 44 | - "A list with groupnames which the exclusions are to be attached" 45 | type: list 46 | elements: str 47 | default: [] 48 | required: false 49 | os_type: 50 | description: 51 | - "Define the operating system for the exclusion. Required if I(state=present)" 52 | type: str 53 | required: false 54 | choices: 55 | - windows 56 | - linux 57 | os_path: 58 | description: 59 | - "Os path of the exclusion." 60 | - "If the path a folder, the path must end with / (linux) or \\ (windows)" 61 | type: str 62 | required: true 63 | include_subfolders: 64 | description: 65 | - "If yes, the exclusion will scope subfolders as well. Is ignored if I(os_path) is not a folder (does not end with / (linux) or \\ (windows))" 66 | type: bool 67 | required: false 68 | default: no 69 | ef_alerts_mitigation: 70 | description: 71 | - "Exclusion Function to exclude I(os_path) for alerts and mitigation" 72 | type: bool 73 | required: false 74 | default: yes 75 | ef_binary_vault: 76 | description: 77 | - "Exclusion Function to exclude I(os_path) for Binary Vaults" 78 | type: bool 79 | required: false 80 | default: no 81 | mode: 82 | description: 83 | - "Defines the exclusion mode for this exclusion. Required if I(state=present)" 84 | type: str 85 | required: false 86 | choices: 87 | - suppress_alerts 88 | - interoperability 89 | - interoperability_extended 90 | - performance_focus 91 | - performance_focus_extended 92 | description: 93 | description: 94 | - "A short description to describe the exclusion" 95 | type: str 96 | required: false 97 | default: "" 98 | author: 99 | - "Marco Wester (@mwester117) " 100 | - "Lasse Wackers (@mordecaine) " 101 | requirements: 102 | - "deepdiff >= 5.6" 103 | notes: 104 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 105 | - "Currently only supported in single-account management consoles" 106 | - "Currently not applicable for account level exclusions" 107 | - "Currently not applicable for MacOS" 108 | ''' 109 | 110 | EXAMPLES = r''' 111 | --- 112 | - name: Create exclusion in site scope 113 | sva.sentinelone.sentinelone_path_exclusions: 114 | console_url: "https://XXXXX.sentinelone.net" 115 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 116 | site_name: "test" 117 | os_path: "C:\\Test1234\\" 118 | mode: "performance_focus" 119 | os_type: "windows" 120 | - name: Create exclusion in single group 121 | sva.sentinelone.sentinelone_path_exclusions: 122 | console_url: "https://XXXXX.sentinelone.net" 123 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 124 | site_name: "test" 125 | groups: "MariaDB" 126 | os_path: "C:\\Test1234\\" 127 | mode: "interoperability_extended" 128 | os_type: "windows" 129 | - name: Create exclusion in multiple groups 130 | sva.sentinelone.sentinelone_path_exclusions: 131 | state: "present" 132 | console_url: "https://XXXXX.sentinelone.net" 133 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 134 | site_name: "test" 135 | groups: 136 | - "MariaDB" 137 | - "MaxDB" 138 | os_path: "C:\\Test1234\\" 139 | mode: "performance_focus_extended" 140 | os_type: "windows" 141 | - name: Create exclusion in multiple groups and disable automatic upload to Binary Vault 142 | sva.sentinelone.sentinelone_path_exclusions: 143 | state: "present" 144 | console_url: "https://XXXXX.sentinelone.net" 145 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 146 | site_name: "test" 147 | groups: 148 | - "MariaDB" 149 | - "MaxDB" 150 | include_subfolders: true 151 | os_path: "C:\\Test1234\\" 152 | mode: "performance_focus_extended" 153 | os_type: "windows" 154 | ef_binary_vault: true 155 | - name: Delete exclusion in site scope 156 | sva.sentinelone.sentinelone_path_exclusions: 157 | state: "absent" 158 | console_url: "https://XXXXX.sentinelone.net" 159 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 160 | site_name: "msd" 161 | os_path: "C:\\Test1234\\" 162 | - name: Delete exclusion in group scope 163 | sva.sentinelone.sentinelone_path_exclusions: 164 | state: "absent" 165 | console_url: "https://XXXXX.sentinelone.net" 166 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 167 | site_name: "msd" 168 | groups: 169 | - "MariaDB" 170 | - "MaxDB" 171 | os_path: "C:\\Test1234\\" 172 | ''' 173 | 174 | RETURN = r''' 175 | --- 176 | original_message: 177 | description: Get detailed infos about the changes made 178 | returned: on success 179 | type: str 180 | sample: [{"changes": {"values_changed": {"root['mode']": 181 | {"new_value": "disable_all_monitors_deep", "old_value": "disable_all_monitors"}}}, "siteId": ["99999999999999999"]}] 182 | message: 183 | description: Get basic infos about the changes made 184 | returned: on success 185 | type: list 186 | sample: [ "Exclusion is missing in a group. Creating exclusion." ] 187 | ''' 188 | 189 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 190 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase, lib_imp_errors 191 | from ansible.module_utils.six.moves.urllib.parse import quote_plus 192 | from ansible.module_utils.urls import re 193 | 194 | 195 | class SentineloneExclusions(SentineloneBase): 196 | def __init__(self, module: AnsibleModule): 197 | """ 198 | Initialization of the Exclusions object 199 | 200 | :param module: Requires the AnsibleModule Object for parsing the parameters 201 | :type module: AnsibleModule 202 | """ 203 | 204 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 205 | # super Class 206 | super().__init__(module) 207 | 208 | # Set module specific parameters 209 | self.description = module.params["description"] 210 | self.exclusion_path = module.params["os_path"] 211 | self.os_type = module.params["os_type"] 212 | self.mode = module.params["mode"] 213 | self.ef_binary_vault = module.params["ef_binary_vault"] 214 | self.include_subfolders = module.params["include_subfolders"] 215 | self.ef_alerts_mitigation = module.params["ef_alerts_mitigation"] 216 | 217 | # Do sanity checks 218 | self.check_sanity(self.ef_alerts_mitigation, self.ef_binary_vault, module) 219 | 220 | # Translate mode to API value 221 | self.mode_name = self.get_mode_name(self.mode, self.os_type, self.state, module) 222 | 223 | self.path_exclusion_type = self.get_path_exclusion_type(self.include_subfolders, self.exclusion_path) 224 | self.actions = self.get_actions(self.ef_alerts_mitigation, self.ef_binary_vault) 225 | 226 | self.current_group_ids = list(map(lambda current_group_id_name: current_group_id_name[0], 227 | self.current_group_ids_names)) 228 | 229 | self.desired_state_exclusion = self.get_desired_state_exclusion_body() 230 | self.current_exclusions = self.get_current_exclusions(self.current_group_ids, self.exclusion_path, module) 231 | 232 | self.current_exclusion_ids = list(map(lambda exclusion: exclusion['id'], self.current_exclusions['data'])) 233 | 234 | @staticmethod 235 | def get_mode_name(mode: str, os_type: str, state: str, module: AnsibleModule): 236 | """ 237 | Map the web UI exclusion mode names to API names 238 | 239 | :param mode: The mode which should be set for the exclusion 240 | :type mode: str 241 | :param os_type: The os for which the exclusion will be created 242 | :type os_type: str 243 | :param state: Present or absent 244 | :type state: str 245 | :param module: Ansible module for error handling 246 | :type module: AnsibleModule 247 | :return: API mapping of the exclusion mode 248 | :rtype: str 249 | """ 250 | 251 | if mode == "suppress_alerts": 252 | return "suppress" 253 | elif mode == "interoperability" and os_type == "windows": 254 | return "disable_in_process_monitor" 255 | elif mode == "interoperability_extended" and os_type == "windows": 256 | return "disable_in_process_monitor_deep" 257 | elif mode == "performance_focus": 258 | return "disable_all_monitors" 259 | elif mode == "performance_focus_extended": 260 | return "disable_all_monitors_deep" 261 | elif state == "present": 262 | module.fail_json(msg=f"The mode {mode} is not compatible with os {os_type}") 263 | 264 | @staticmethod 265 | def get_path_exclusion_type(include_subfolders: bool, exclusion_path: str): 266 | """ 267 | Set path exclusion type. If trailing slash or backslash is present path is handled as a folder. Here you can 268 | optionally enable recursive exclusion with include_subfolders. If trailing slash or backslash is not present it 269 | is automatically handeled as file type exclusion 270 | 271 | :param include_subfolders: True if exclusion should match subfolders. False if not 272 | :type include_subfolders: bool 273 | :param exclusion_path: Path wich should be excluded 274 | :type exclusion_path: str 275 | :return: API mapping for the exclusion type 276 | :rtype: str 277 | """ 278 | 279 | # Get path type (folder or file) 280 | if re.search(r"[/\\]$", exclusion_path) and not include_subfolders: 281 | path_exclusion_type = "folder" 282 | elif re.search(r"[/\\]$", exclusion_path) and include_subfolders: 283 | path_exclusion_type = "subfolders" 284 | else: 285 | path_exclusion_type = "file" 286 | 287 | return path_exclusion_type 288 | 289 | @staticmethod 290 | def get_actions(ef_alerts_mitigation: bool, ef_binary_vault: bool): 291 | """ 292 | Set actions for Exclusion Function 293 | 294 | :param ef_alerts_mitigation: Enable or disable exclusion for alerts and mitigation 295 | :type ef_alerts_mitigation: bool 296 | :param ef_binary_vault: Enable or disable Binary Vault uploads 297 | :type ef_binary_vault: bool 298 | :return: API mapping for exclusion functions 299 | :rtype: list 300 | """ 301 | 302 | actions = [] 303 | if ef_alerts_mitigation: 304 | actions.append("detect") 305 | if ef_binary_vault: 306 | actions.append("upload") 307 | 308 | return actions 309 | 310 | def get_desired_state_exclusion_body(self): 311 | """ 312 | Create API object 313 | 314 | :return: API body for create request 315 | :rtype: dict 316 | """ 317 | desired_state_exclusion = { 318 | "filter": { 319 | "siteIds": [self.site_id] 320 | }, 321 | "data": { 322 | "type": "path", 323 | "value": self.exclusion_path, 324 | "mode": self.mode_name, 325 | "source": "user", 326 | "pathExclusionType": self.path_exclusion_type, 327 | "description": self.description, 328 | "actions": self.actions, 329 | "osType": self.os_type 330 | } 331 | } 332 | 333 | if self.current_group_ids_names: 334 | desired_state_exclusion["filter"]["groupIds"] = self.current_group_ids 335 | 336 | return desired_state_exclusion 337 | 338 | @staticmethod 339 | def get_delete_exclusion_body(current_exclusion_ids: list): 340 | """ 341 | Delete API object 342 | 343 | :param current_exclusion_ids: Ids of the exclsions which schould be deleted 344 | :type current_exclusion_ids: list 345 | :return: API body for delete request 346 | :rtype: dict 347 | """ 348 | 349 | delete_body = { 350 | "data": { 351 | "ids": current_exclusion_ids, 352 | "type": "path" 353 | } 354 | } 355 | 356 | return delete_body 357 | 358 | def get_current_exclusions(self, current_group_ids: list, exclusion_path: str, module: AnsibleModule): 359 | """ 360 | Get currently existing exclusion objects from API. If in group scope it returns the exclusion object for every 361 | group where the exclusion exists 362 | 363 | :param current_group_ids: Group ids of the groups where the exclusion should exist 364 | :type current_group_ids: list 365 | :param exclusion_path: path which should be excluded 366 | :type exclusion_path: str 367 | :param module: Ansible module for error handling 368 | :type module: AnsibleModule 369 | :return: API response with exclusion objects if existing 370 | :rtype: dict 371 | """ 372 | 373 | api_url = self.api_endpoint_exclusions + (f"?siteIds={quote_plus(self.site_id)}&" 374 | f"value={quote_plus(exclusion_path)}&" 375 | f"osTypes={quote_plus(self.os_type)}&" 376 | f"type=path" 377 | ) 378 | if current_group_ids: 379 | # Scope is group level 380 | api_url += f"&groupIds={quote_plus(','.join(current_group_ids))}" 381 | 382 | error_msg = "Failed to get current exclusions." 383 | response = self.api_call(module, api_url, error_msg=error_msg) 384 | 385 | return response 386 | 387 | def delete_exclusions(self, module: AnsibleModule): 388 | """ 389 | Delete existing exclusions 390 | 391 | :param module: Ansible module for error handling 392 | :type module: AnsibleModule 393 | :return: API response of the delete query 394 | :rtype: dict 395 | """ 396 | 397 | if self.current_exclusion_ids: 398 | api_url = self.api_endpoint_exclusions 399 | delete_body = self.get_delete_exclusion_body(self.current_exclusion_ids) 400 | error_msg = "Failed to delete exclusions." 401 | response = self.api_call(module, api_url, "DELETE", body=delete_body, error_msg=error_msg) 402 | 403 | if response['data']['affected'] == 0: 404 | module.fail_json(msg="Exclusions should have been deleted via API but API result was empty") 405 | else: 406 | response = "Nothing to delete" 407 | 408 | return response 409 | 410 | def update_exclusions(self, module: AnsibleModule, exclusion_id): 411 | """ 412 | Update exclusions 413 | 414 | :param module: Ansible module for error handling 415 | :type module: AnsibleModule 416 | :return: API response of the create query 417 | :rtype: dict 418 | """ 419 | api_url = self.api_endpoint_exclusions 420 | update_body = self.get_desired_state_exclusion_body() 421 | update_body['data']['id'] = exclusion_id 422 | error_msg = "Failed to update exclusions." 423 | response = self.api_call(module, api_url, "PUT", body=update_body, error_msg=error_msg) 424 | 425 | if len(response['data']) == 0: 426 | module.fail_json(msg="Exclusions could not be updated - API result was empty") 427 | 428 | return response 429 | 430 | def create_exclusions(self, module: AnsibleModule): 431 | """ 432 | Create exclusions 433 | 434 | :param module: Ansible module for error handling 435 | :type module: AnsibleModule 436 | :return: API response of the create query 437 | :rtype: dict 438 | """ 439 | api_url = self.api_endpoint_exclusions 440 | create_body = self.get_desired_state_exclusion_body() 441 | error_msg = "Failed to create exclusions." 442 | response = self.api_call(module, api_url, "POST", body=create_body, error_msg=error_msg) 443 | 444 | if len(response['data']) == 0: 445 | module.fail_json(msg="Exclusions should have been deleted via API but API result was empty") 446 | 447 | return response 448 | 449 | @staticmethod 450 | def check_sanity(ef_alerts_mitigation: bool, ef_binary_vault: bool, module: AnsibleModule): 451 | """ 452 | Check if the passed module arguments are contradicting each other 453 | 454 | :param ef_alerts_mitigation: Enable or disable exclusion for alerts and mitigation 455 | :type ef_alerts_mitigation: bool 456 | :param ef_binary_vault: Enable or disable Binary Vault uploads 457 | :type ef_binary_vault: bool 458 | :param module: Ansible module for error handling 459 | :type module: AnsibleModule 460 | """ 461 | 462 | # Check if at least one of ef_alerts_mitigation and ef_binary_vault is true 463 | if not ef_alerts_mitigation and not ef_binary_vault: 464 | module.fail_json(msg="One of the following options needs to be true: ef_alerts_mitigation, ef_binary_vault") 465 | 466 | 467 | def run_module(): 468 | # define available arguments/parameters a user can pass to the module 469 | module_args = dict( 470 | console_url=dict(type='str', required=True), 471 | token=dict(type='str', required=True, no_log=True), 472 | state=dict(type='str', required=False, default='present', choices=['present', 'absent']), 473 | site_name=dict(type='str', required=True), 474 | groups=dict(type='list', required=False, elements='str', default=[]), 475 | os_type=dict(type='str', required=False, choices=['windows', 'linux']), 476 | os_path=dict(type='str', required=True), 477 | include_subfolders=dict(type='bool', required=False, default=False), 478 | ef_alerts_mitigation=dict(type='bool', required=False, default=True), 479 | ef_binary_vault=dict(type='bool', required=False, default=False), 480 | mode=dict(type='str', required=False, choices=[ 481 | 'suppress_alerts', 482 | 'interoperability', 483 | 'interoperability_extended', 484 | 'performance_focus', 485 | 'performance_focus_extended' 486 | ]), 487 | description=dict(type='str', required=False, default=""), 488 | ) 489 | 490 | module = AnsibleModule( 491 | argument_spec=module_args, 492 | required_if=[ 493 | ('state', 'present', ('mode', 'os_type')) 494 | ], 495 | supports_check_mode=False 496 | ) 497 | 498 | if not lib_imp_errors['has_lib']: 499 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 500 | 501 | # Create exclusion Object 502 | exclusion_obj = SentineloneExclusions(module) 503 | 504 | current_exclusions = exclusion_obj.current_exclusions 505 | desired_state_exclusion = exclusion_obj.desired_state_exclusion 506 | # Groups where the exclusion currently exists 507 | current_group_ids_names = exclusion_obj.current_group_ids_names 508 | 509 | state = exclusion_obj.state 510 | 511 | diffs = [] 512 | basic_message = [] 513 | if state == 'present': 514 | if current_group_ids_names: 515 | # desired_exclusions_count is the count of the currently existing groups because we want the exlusion to 516 | # exist in every group 517 | desired_exclusions_count = len(current_group_ids_names) 518 | # current_exclusions_count is the count of the currently existing exclusions 519 | current_exclusions_count = current_exclusions['pagination']['totalItems'] 520 | # if scope is group level 521 | if desired_exclusions_count == current_exclusions_count: 522 | # All exclusions exist. Check if they differ from desired state 523 | for current_exclusion in current_exclusions["data"]: 524 | diff = exclusion_obj.merge_compare(current_exclusion, desired_state_exclusion['data'])[0] 525 | if diff: 526 | group_id = current_exclusion['scope']['groupIds'][0] 527 | # Get name for group with group_id 528 | group_name = list(filter(lambda filterobj: filterobj[0] == group_id, 529 | current_group_ids_names))[0][1] 530 | diffs.append({'changes': dict(diff), 'groupId': group_id, "exclusion_id": current_exclusion['id']}) 531 | basic_message.append(f"Exclusion exists in group {group_name} but is not up-to-date. " 532 | f"Updating exclusion.") 533 | else: 534 | # Not all exclusions exists. 535 | message = "Exclusion is missing in a group. Creating exclusion." 536 | basic_message.append(message) 537 | diffs.append({'changes': message}) 538 | else: 539 | # if scope is site level 540 | site_name = exclusion_obj.site_name 541 | if current_exclusions['pagination']['totalItems'] == 0: 542 | message = f'Exclusion is missing in site {site_name}. Creating exclusion.' 543 | basic_message.append(message) 544 | diffs.append({'changes': message}) 545 | else: 546 | # Exclusion exits. Check if it differs from desired state. 547 | current_exclusion = current_exclusions['data'][0] 548 | diff = exclusion_obj.merge_compare(current_exclusion, desired_state_exclusion['data'])[0] 549 | if diff: 550 | diffs.append({'changes': dict(diff), 551 | 'siteId': current_exclusion['scope']['siteIds'], 552 | 'exclusion_id': current_exclusion['id'] 553 | }) 554 | basic_message.append(f"Exclusion exists in site {site_name} but is not up-to-date. " 555 | f"Updating exclusion.") 556 | 557 | if diffs: 558 | if diffs[0].get('exclusion_id'): 559 | # Update Exclusions 560 | exclusion_obj.update_exclusions(module, exclusion_id=diffs[0]['exclusion_id']) 561 | 562 | else: 563 | # Create Exclusions 564 | exclusion_obj.create_exclusions(module) 565 | 566 | else: 567 | basic_message.append("Nothing to change, all desired changes are already set") 568 | 569 | else: 570 | if current_exclusions['pagination']['totalItems'] != 0: 571 | # Exclusions should be deleted 572 | exclusion_obj.delete_exclusions(module) 573 | diffs.append({'changes': 'Deleted all exclusions in Scope'}) 574 | else: 575 | basic_message.append("Nothing to change, exclusion does not exist") 576 | 577 | result = dict( 578 | changed=False, 579 | original_message=diffs, 580 | message=basic_message 581 | ) 582 | 583 | # If we made changes to the objects the list diffs is not empty. 584 | # So we can use it to update result['changes'] to True if necessary 585 | if diffs: 586 | result['changed'] = True 587 | 588 | # in the event of a successful module execution, you will want to 589 | # simple AnsibleModule.exit_json(), passing the key/value results 590 | module.exit_json(**result) 591 | 592 | 593 | def main(): 594 | run_module() 595 | 596 | 597 | if __name__ == '__main__': 598 | main() 599 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_policies.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | DOCUMENTATION = r''' 10 | --- 11 | module: sentinelone_policies 12 | short_description: "Manage SentinelOne Policies" 13 | version_added: "1.0.0" 14 | description: 15 | - "This module is able to update policies in SentinelOne" 16 | options: 17 | console_url: 18 | description: 19 | - "Insert your management console URL" 20 | type: str 21 | required: true 22 | token: 23 | description: 24 | - "SentinelOne API auth token to authenticate at the management API" 25 | type: str 26 | required: true 27 | inherit: 28 | description: 29 | - "Inherit policy from upper scope" 30 | - "If set to yes I(policy) will be ignored and the policy will be inherited from upper scope" 31 | type: bool 32 | default: no 33 | required: false 34 | site_name: 35 | description: 36 | - "Name of the site in SentinelOne" 37 | type: str 38 | required: true 39 | groups: 40 | description: 41 | - "Set this option to set the scope to group level" 42 | - "A list with groupnames where the policy should be changed" 43 | type: list 44 | elements: str 45 | default: [] 46 | required: false 47 | policy: 48 | description: 49 | - "Define the settings which should be set in policy. Available options can be referred in API documentation" 50 | - "e.g. agentUiOn or snapshotsOn" 51 | - "Required if I(inherit=no)" 52 | - "Will be ignored if I(inherit=yes)" 53 | type: dict 54 | required: false 55 | author: 56 | - "Marco Wester (@mwester117) " 57 | requirements: 58 | - "deepdiff >= 5.6" 59 | notes: 60 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 61 | - "Currently only supported in single-account management consoles" 62 | - "Currently not applicable for account level policies" 63 | ''' 64 | 65 | EXAMPLES = r''' 66 | --- 67 | - name: Set custom policy on multiple groups 68 | sva.sentinelone.sentinelone_policies: 69 | console_url: "https://XXXXX.sentinelone.net" 70 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 71 | site_name: "test" 72 | groups: 73 | - group1 74 | - group2 75 | policy: 76 | agentUiOn: false 77 | agentUi: 78 | agentUiOn: false 79 | - name: Set custom policy on site 80 | sva.sentinelone.sentinelone_policies: 81 | console_url: "https://XXXXX.sentinelone.net" 82 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 83 | site_name: "test" 84 | policy: 85 | agentUiOn: false 86 | agentUi: 87 | agentUiOn: false 88 | - name: Revert to group default policy inherited from site 89 | sva.sentinelone.sentinelone_policies: 90 | console_url: "https://XXXXX.sentinelone.net" 91 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 92 | site_name: "test" 93 | inherit: "yes" 94 | groups: 95 | - group1 96 | - group2 97 | - name: Revert to site default policy inherited from account 98 | sva.sentinelone.sentinelone_policies: 99 | console_url: "https://XXXXX.sentinelone.net" 100 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 101 | site_name: "test" 102 | inherit: "yes" 103 | ''' 104 | 105 | RETURN = r''' 106 | --- 107 | original_message: 108 | description: Get detailed infos about the changes made 109 | type: str 110 | returned: on success 111 | sample: [{"changes": {"values_changed": {"root['agentUi']['agentUiOn']": {"new_value": false, "old_value": true}, 112 | "root['agentUiOn']": {"new_value": false, "old_value": true}}}, "groupId": "99999999999999"}, 113 | {"changes": {"values_changed": {"root['agentUi']['agentUiOn']": {"new_value": false, "old_value": true}, 114 | "root['agentUiOn']": {"new_value": false, "old_value": true}}}, "groupId": "99999999999999"}] 115 | message: 116 | description: Get basic infos about the changes made 117 | type: list 118 | returned: on success 119 | sample: ["Updating policy in group with id 99999999999999", "Updating policy in group with id 99999999999999"] 120 | ''' 121 | 122 | from ansible.module_utils.basic import AnsibleModule 123 | from ansible.module_utils.basic import missing_required_lib 124 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase, lib_imp_errors 125 | 126 | 127 | class SentinelonePolicies(SentineloneBase): 128 | def __init__(self, module: AnsibleModule): 129 | """ 130 | Initialization of the policies object 131 | 132 | :param module: Requires the AnsibleModule Object for parsing the parameters 133 | :type module: AnsibleModule 134 | """ 135 | 136 | # super class __init__ only expects "state" not "inherit". Translating it here 137 | module.params["state"] = module.params["inherit"] 138 | 139 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 140 | # super Class 141 | super().__init__(module) 142 | 143 | # Set module specific parameters 144 | # Translating "state" back to "inherit" 145 | self.inherit = self.state 146 | self.desired_state_policy = module.params["policy"] 147 | 148 | def get_current_policy(self, site_group_id: str, module: AnsibleModule): 149 | """ 150 | Get the policy which is currently set from API. Can be used on site or group scope 151 | 152 | :param site_group_id: Site or group id 153 | :type site_group_id: str 154 | :param module: Ansible module for error handling 155 | :type module: AnsibleModule 156 | :return: Policy object 157 | :rtype: dict 158 | """ 159 | 160 | # API call to get the policy which is currently set. Can be used on site or group level 161 | if self.current_group_ids_names: 162 | api_url = f"{self.api_endpoint_groups}/{site_group_id}/policy" 163 | else: 164 | api_url = f"{self.api_endpoint_sites}/{site_group_id}/policy" 165 | 166 | error_msg = f"Failed to get current policy for site or group with id {site_group_id}." 167 | response = self.api_call(module, api_url, error_msg=error_msg) 168 | 169 | return response 170 | 171 | def update_policy(self, site_group_id: str, update_body: dict, module: AnsibleModule): 172 | """ 173 | API call to update the policy. Can be used on site or group level 174 | 175 | :param site_group_id: Site or group id 176 | :type site_group_id: str 177 | :param update_body: Dictionary object which is used for updating the existing policy object 178 | :type update_body: dict 179 | :param module: Ansible module for error handling 180 | :type module: AnsibleModule 181 | :return: API response 182 | :rtype: dict 183 | """ 184 | 185 | if self.current_group_ids_names: 186 | # group level scope 187 | api_url = f"{self.api_endpoint_groups}/{site_group_id}/policy" 188 | else: 189 | # site level scope 190 | api_url = f"{self.api_endpoint_sites}/{site_group_id}/policy" 191 | 192 | error_msg = f"Failed to update policy with site or group id {site_group_id}." 193 | response = self.api_call(module, api_url, "PUT", body=update_body, error_msg=error_msg) 194 | 195 | if not response['data']: 196 | module.fail_json(msg=(f"Error in update_policy with site or group id {site_group_id}: Policy should have " 197 | "been updated via API but result was empty")) 198 | 199 | return response 200 | 201 | def revert_policy(self, site_group_id: str, module: AnsibleModule): 202 | """ 203 | API-call to enable policy inheritance. Can be used on site or group level 204 | 205 | :param site_group_id: site or group id 206 | :type site_group_id: str 207 | :param module: Ansible module for error handling 208 | :type module: AnsibleModule 209 | :return: API response 210 | :rtype: dict 211 | """ 212 | 213 | if self.current_group_ids_names: 214 | api_url = f"{self.api_endpoint_groups}/{site_group_id}/revert-policy" 215 | else: 216 | api_url = f"{self.api_endpoint_sites}/{site_group_id}/revert-policy" 217 | 218 | error_msg = f"Failed to revert policy with site or group id {site_group_id}." 219 | response = self.api_call(module, api_url, "PUT", error_msg=error_msg) 220 | 221 | if not response['data']['success']: 222 | module.fail_json(msg=(f"Error in revert_pollicy with site or group id {site_group_id}: Policy should have " 223 | "been updated via API but result was empty")) 224 | 225 | return response 226 | 227 | @staticmethod 228 | def get_update_body(policy_settings: dict): 229 | """ 230 | Prepare the merged object for post request 231 | 232 | :param policy_settings: desired state policy settings 233 | :type policy_settings: dict 234 | :return: update body for API 235 | :rtype: dict 236 | """ 237 | 238 | # Remove deprecated policy settings. The module would not work correctly in some circumstances 239 | del policy_settings['agentNotification'] 240 | del policy_settings['agentUiOn'] 241 | 242 | policy_object = {'data': policy_settings} 243 | 244 | return policy_object 245 | 246 | 247 | def run_module(): 248 | # define available arguments/parameters a user can pass to the module 249 | module_args = dict( 250 | console_url=dict(type='str', required=True), 251 | token=dict(type='str', required=True, no_log=True), 252 | inherit=dict(type='bool', required=False, default='false'), 253 | site_name=dict(type='str', required=True), 254 | groups=dict(type='list', required=False, elements='str', default=[]), 255 | policy=dict(type='dict', required=False), 256 | ) 257 | 258 | module = AnsibleModule( 259 | argument_spec=module_args, 260 | required_if=[ 261 | ('inherit', False, ('policy',)) 262 | ], 263 | supports_check_mode=False 264 | ) 265 | 266 | if not lib_imp_errors['has_lib']: 267 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 268 | 269 | # Create policy Object 270 | policy_obj = SentinelonePolicies(module) 271 | current_group_ids_names = policy_obj.current_group_ids_names 272 | inherit = policy_obj.inherit 273 | 274 | diffs = [] 275 | basic_message = [] 276 | if not inherit: 277 | # if we want to set a custom policy 278 | if current_group_ids_names: 279 | # if scope is group level 280 | for current_group_id_name in current_group_ids_names: 281 | current_group_id = current_group_id_name[0] 282 | # check if every group has the desired settings already 283 | current_policy = policy_obj.get_current_policy(current_group_id, module) 284 | desired_state_policy = policy_obj.desired_state_policy 285 | diff, merged_policy = policy_obj.merge_compare(current_policy['data'], desired_state_policy) 286 | if diff: 287 | # if group policy is different from desired state, update it 288 | current_group_name = current_group_id_name[1] 289 | diffs.append({'changes': dict(diff), 'groupId': current_group_id}) 290 | basic_message.append(f"Updating policy for group {current_group_name}") 291 | update_body = policy_obj.get_update_body(merged_policy) 292 | policy_obj.update_policy(current_group_id, update_body, module) 293 | else: 294 | # if scope is site level 295 | # check if site has the desired settings already 296 | site_name = policy_obj.site_name 297 | site_id = policy_obj.site_id 298 | current_policy = policy_obj.get_current_policy(site_id, module) 299 | desired_state_policy = policy_obj.desired_state_policy 300 | diff, merged_policy = policy_obj.merge_compare(current_policy['data'], desired_state_policy) 301 | if diff: 302 | # if site policy is different from desired state, update it 303 | diffs.append({'changes': dict(diff), 'SiteId': site_id}) 304 | basic_message.append(f"Updating policy for site {site_name}") 305 | update_body = policy_obj.get_update_body(merged_policy) 306 | policy_obj.update_policy(site_id, update_body, module) 307 | else: 308 | # if we want to enable inheritance 309 | if current_group_ids_names: 310 | # if scope is group level 311 | for current_group_id_name in current_group_ids_names: 312 | current_group_id = current_group_id_name[0] 313 | current_policy = policy_obj.get_current_policy(current_group_id, module) 314 | if not current_policy["data"]["inheritedFrom"]: 315 | # If inheritedFrom is "None" it will enable inheritance 316 | current_group_name = current_group_id_name[1] 317 | diffs.append({'changes': "Inheritance from site scope enabled", 'groupId': current_group_id}) 318 | basic_message.append(f"Enable inheritance from site scope in group {current_group_name}") 319 | policy_obj.revert_policy(current_group_id, module) 320 | else: 321 | # if scope is site level 322 | site_name = policy_obj.site_name 323 | site_id = policy_obj.site_id 324 | current_policy = policy_obj.get_current_policy(site_id, module) 325 | if not current_policy["data"]["inheritedFrom"]: 326 | # If inheritedFrom is "None" it will enable inheritance 327 | diffs.append({'changes': "Inheritance from account scope enabled", 'siteId': site_id}) 328 | basic_message.append(f"Enable inheritance from account scope in site {site_name}") 329 | policy_obj.revert_policy(site_id, module) 330 | 331 | result = dict( 332 | changed=False, 333 | original_message=diffs, 334 | message=basic_message 335 | ) 336 | 337 | # If we made changes to the objects the list diffs is not empty. 338 | # So we can use it to update result['changes'] to True if necessary 339 | if diffs: 340 | result['changed'] = True 341 | 342 | # in the event of a successful module execution, you will want to 343 | # simple AnsibleModule.exit_json(), passing the key/value results 344 | module.exit_json(**result) 345 | 346 | 347 | def main(): 348 | run_module() 349 | 350 | 351 | if __name__ == '__main__': 352 | main() 353 | -------------------------------------------------------------------------------- /plugins/modules/sentinelone_sites.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2024, Marco Wester 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | from __future__ import (absolute_import, division, print_function) 7 | __metaclass__ = type 8 | 9 | DOCUMENTATION = r''' 10 | --- 11 | module: sentinelone_sites 12 | short_description: "Manage SentinelOne Sites" 13 | version_added: "1.0.0" 14 | description: 15 | - "This module is able to create, update and delete sites in SentinelOne" 16 | options: 17 | console_url: 18 | description: 19 | - "Insert your management console URL" 20 | type: str 21 | required: true 22 | token: 23 | description: 24 | - "SentinelOne API auth token to authenticate at the management API" 25 | type: str 26 | required: true 27 | state: 28 | description: 29 | - "Select the state of the site" 30 | type: str 31 | default: present 32 | required: false 33 | choices: 34 | - present 35 | - absent 36 | name: 37 | description: 38 | - "The name of the site" 39 | type: str 40 | required: true 41 | site_type: 42 | description: 43 | - "The type of the site" 44 | type: str 45 | required: false 46 | default: Paid 47 | choices: 48 | - Trial 49 | - Paid 50 | license_type: 51 | description: 52 | - "The SKU to use" 53 | type: str 54 | required: false 55 | choices: 56 | - core 57 | - control 58 | - complete 59 | default: core 60 | total_agents: 61 | description: 62 | - "Count of total agent licenses to be assigned to site" 63 | - "Use B(-1) (default) to set to B(unlimited licenses) in site" 64 | type: int 65 | required: false 66 | default: -1 67 | expiration_date: 68 | description: 69 | - "Sets the expiration date" 70 | - "Use B(-1) (default) to set to B(max expiration) available. This is either unlimited or account expiration date." 71 | - "Format is ISO 8601 without microseconds" 72 | - "Examples:" 73 | - "2022-03-15T10:20+00:00" 74 | - "2022-03-15T11:21+0100" 75 | type: str 76 | required: false 77 | default: "-1" 78 | description: 79 | description: 80 | - "Description for the site" 81 | type: str 82 | required: false 83 | default: "" 84 | author: 85 | - "Marco Wester (@mwester117) " 86 | requirements: 87 | - "deepdiff >= 5.6" 88 | notes: 89 | - "Python module deepdiff. Tested with version >=5.6. Lower version may work too" 90 | - "Currently only supported in single-account management consoles" 91 | - "Policy is always inherited from Account scope. If you want to change the policy please use sentinelone_policies module" 92 | ''' 93 | 94 | EXAMPLES = r''' 95 | --- 96 | - name: Create / update site 97 | sva.sentinelone.sentinelone_sites: 98 | console_url: "https://XXXXX.sentinelone.net" 99 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 100 | name: "test" 101 | license_type: "control" 102 | expiration_date: "2022-06-01T12:00+01:00" 103 | description: "Testsite" 104 | - name: Delete site 105 | sva.sentinelone.sentinelone_sites: 106 | state: "absent" 107 | console_url: "https://XXXXX.sentinelone.net" 108 | token: "XXXXXXXXXXXXXXXXXXXXXXXXXXX" 109 | name: "test" 110 | ''' 111 | 112 | RETURN = r''' 113 | --- 114 | original_message: 115 | description: Get detailed infos about the changes made 116 | type: str 117 | returned: on success 118 | sample: {'changes': {'values_changed': {"root['description']": {'new_value': 'Test1', 'old_value': 'Test'}, 119 | "root['unlimitedExpiration']": {'new_value': True, 'old_value': False}}}, 'siteName': 'test'} 120 | message: 121 | description: Get basic infos about the changes made 122 | type: str 123 | returned: on success 124 | sample: Site exists but is not up-to-date. Updating site. 125 | ''' 126 | 127 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 128 | from ansible_collections.sva.sentinelone.plugins.module_utils.sentinelone.sentinelone_base import SentineloneBase, lib_imp_errors 129 | from datetime import datetime, timezone 130 | 131 | 132 | class SentineloneSite(SentineloneBase): 133 | def __init__(self, module: AnsibleModule): 134 | """ 135 | Initialization of the site object 136 | 137 | :param module: Requires the AnsibleModule Object for parsing the parameters 138 | :type module: AnsibleModule 139 | """ 140 | 141 | module.params['site_name'] = module.params['name'] 142 | 143 | # self.token, self.console_url, self.site_name, self.state, self.api_endpoint_*, self.group_names will be set in 144 | # super Class 145 | super().__init__(module) 146 | 147 | # Set module specific parameters 148 | self.site_type = module.params["site_type"] 149 | self.license_type = module.params["license_type"] 150 | self.total_agents = module.params["total_agents"] 151 | self.expiration_date = module.params["expiration_date"] 152 | self.description = module.params["description"] 153 | 154 | # Do sanity checks 155 | self.check_sanity(self.state, self.license_type, self.total_agents, self.expiration_date, 156 | self.current_account, module) 157 | 158 | def desired_state_site_body(self): 159 | """ 160 | Create body for site API requests 161 | 162 | :return: Body for site API requests 163 | :rtype: dict 164 | """ 165 | 166 | desired_state_site_body = { 167 | "accountId": self.account_id, 168 | "siteType": self.site_type, 169 | "inherits": True, 170 | "name": self.site_name, 171 | "licenses": { 172 | "bundles": [ 173 | { 174 | "name": self.license_type, 175 | "surfaces": [ 176 | { 177 | "name": "Total Agents", 178 | "count": self.total_agents 179 | } 180 | ] 181 | } 182 | ] 183 | } 184 | } 185 | 186 | if self.expiration_date == "-1": 187 | if self.current_account["unlimitedExpiration"]: 188 | desired_state_site_body["unlimitedExpiration"] = True 189 | else: 190 | desired_state_site_body["unlimitedExpiration"] = False 191 | desired_state_site_body["expiration"] = self.current_account["expiration"] 192 | else: 193 | desired_state_site_body["unlimitedExpiration"] = False 194 | desired_state_site_body["expiration"] = self.expiration_date 195 | 196 | if self.description: 197 | desired_state_site_body["description"] = self.description 198 | 199 | return desired_state_site_body 200 | 201 | def create_site(self, create_body_raw: dict, module: AnsibleModule): 202 | """ 203 | API call to create the site 204 | 205 | :param create_body_raw: Body which should be sent 206 | :type create_body_raw: dict 207 | :param module: Ansible module for error handling 208 | :type module: AnsibleModule 209 | :return: API response 210 | :rtype: dict 211 | """ 212 | 213 | api_url = self.api_endpoint_sites 214 | 215 | create_body = {'data': create_body_raw} 216 | error_msg = "Failed to create site." 217 | response = self.api_call(module, api_url, "POST", body=create_body, error_msg=error_msg) 218 | 219 | if not response['data']: 220 | module.fail_json(msg=("Error in create_site: site should have been created via API " 221 | "but API result was empty")) 222 | 223 | return response 224 | 225 | def delete_site(self, module: AnsibleModule): 226 | """ 227 | API call to delete the site 228 | 229 | :param module: Ansible module for error handling 230 | :type module: AnsibleModule 231 | :return: API response 232 | :rtype: dict 233 | """ 234 | 235 | api_url = f"{self.api_endpoint_sites}/{self.site_id}" 236 | error_msg = "Failed to delete site." 237 | response = self.api_call(module, api_url, "DELETE", error_msg=error_msg) 238 | 239 | if not response['data']['success']: 240 | module.fail_json(msg=("Error in delete_site: Site should have been deleted via API " 241 | "but API result was not 'success'")) 242 | 243 | return response 244 | 245 | def update_site(self, update_body_raw: dict, module: AnsibleModule): 246 | """ 247 | API call to update the site 248 | 249 | :param update_body_raw: Body which should be sent 250 | :type update_body_raw: dict 251 | :param module: Ansible module for error handling 252 | :type module: AnsibleModule 253 | :return: API response 254 | :rtype: dict 255 | """ 256 | 257 | api_url = f"{self.api_endpoint_sites}/{self.site_id}" 258 | 259 | update_body = {'data': update_body_raw} 260 | error_msg = "Failed to update site." 261 | response = self.api_call(module, api_url, "PUT", body=update_body, error_msg=error_msg) 262 | 263 | if not response['data']: 264 | module.fail_json(msg=("Error in update_site: Site should have been updated via API " 265 | "but API result was empty")) 266 | 267 | return response 268 | 269 | def check_sanity(self, state: str, license_type: str, total_agents: int, expiration_date: str, 270 | current_account: dict, module: AnsibleModule): 271 | """ 272 | Check if the passed module arguments are contradicting each other 273 | 274 | 275 | :param state: The state parameter passed to the module 276 | :type state: str 277 | :param license_type: The license_type parameter passed to the module 278 | :type license_type: str 279 | :param total_agents: The total_agents parameter passed to the module 280 | :type total_agents: int 281 | :param expiration_date: The expiration_date parameter passed to the module 282 | :type expiration_date: str 283 | :param current_account: Account information 284 | :type current_account: dict 285 | :param module: Ansible module for error handling 286 | :type module: AnsibleModule 287 | """ 288 | 289 | if state == 'present' and not (total_agents == -1 or total_agents > 0): 290 | module.fail_json(msg="Error: 'total_agents' has to be > 0 or -1.") 291 | 292 | if state == 'present' and not (expiration_date == '' or expiration_date == '-1'): 293 | try: 294 | offset = expiration_date[-5:].replace(':', '') 295 | expiration_date = expiration_date[:-5] + offset 296 | 297 | timeformat = "%Y-%m-%dT%H:%M%z" 298 | expiration_date_parsed = datetime.strptime(expiration_date, timeformat) 299 | except Exception as err: 300 | module.fail_json(msg=f"Error: 'expiration_date' could not be parsed as date. Error: {str(err)}") 301 | 302 | self.expiration_date = expiration_date_parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:00Z") 303 | 304 | elif state == 'present' and expiration_date == '': 305 | module.fail_json(msg="Error: 'expiration_date' has to be -1 or in date format") 306 | 307 | available_license_types = list(map(lambda lic: lic['name'], current_account['licenses']['bundles'])) 308 | if state == 'present' and license_type not in available_license_types: 309 | module.fail_json(msg=f"Error: 'license_type' '{license_type}' not available in account. Available license " 310 | f"types are: {', '.join(available_license_types)}") 311 | 312 | 313 | def run_module(): 314 | # define available arguments/parameters a user can pass to the module 315 | module_args = dict( 316 | console_url=dict(type='str', required=True), 317 | token=dict(type='str', required=True, no_log=True), 318 | state=dict(type='str', required=False, default='present', choices=['present', 'absent']), 319 | name=dict(type='str', required=True), 320 | site_type=dict(type='str', required=False, default='Paid', choices=['Trial', 'Paid']), 321 | license_type=dict(type='str', required=False, choices=['core', 'control', 'complete'], default='core'), 322 | total_agents=dict(type='int', required=False, default=-1), 323 | expiration_date=dict(type='str', required=False, default="-1"), 324 | description=dict(type='str', required=False, default='') 325 | ) 326 | 327 | module = AnsibleModule( 328 | argument_spec=module_args, 329 | supports_check_mode=False 330 | ) 331 | 332 | if not lib_imp_errors['has_lib']: 333 | module.fail_json(msg=missing_required_lib("DeepDiff"), exception=lib_imp_errors['lib_imp_err']) 334 | 335 | # Create site Object 336 | site_obj = SentineloneSite(module) 337 | 338 | site_name = site_obj.site_name 339 | site_id = site_obj.site_id 340 | state = site_obj.state 341 | 342 | diffs = '' 343 | basic_message = '' 344 | if state == 'present': 345 | desired_state_site = site_obj.desired_state_site_body() 346 | if site_id is not None: 347 | # If site exists, check if it is up-to-date 348 | current_site = site_obj.current_site 349 | # Set ignore keys for merge_compare. These settings are not relevant 350 | exclude_path = [ 351 | "root['inherits']", "root['licenses']['bundles'][0]['displayName']", 352 | "root['licenses']['bundles'][0]['majorVersion']", "root['licenses']['bundles'][0]['minorVersion']", 353 | "root['licenses']['bundles'][0]['totalSurfaces']" 354 | ] 355 | diff = site_obj.merge_compare(current_site, desired_state_site, exclude_path)[0] 356 | if diff: 357 | # Update site if it is not up-to-date 358 | diffs = {'changes': dict(diff), 'siteName': site_name} 359 | basic_message = 'Site exists but is not up-to-date. Updating site.' 360 | site_obj.update_site(desired_state_site, module) 361 | else: 362 | # Creates the site if it is missing 363 | basic_message = f'Site is missing. Adding site {site_name}' 364 | diffs = {'changes': basic_message} 365 | site_obj.create_site(desired_state_site, module) 366 | else: 367 | # Site should be deleted 368 | if site_id is not None: 369 | # Check if site exists 370 | basic_message = f'Site {site_name} exists. Deleting site' 371 | diffs = {'changes': basic_message} 372 | site_obj.delete_site(module) 373 | 374 | result = dict( 375 | changed=False, 376 | original_message=diffs, 377 | message=basic_message 378 | ) 379 | 380 | # If we made changes to the objects the list diffs is not empty. 381 | # So we can use it to update result['changes'] to True if necessary 382 | if diffs: 383 | result['changed'] = True 384 | 385 | # in the event of a successful module execution, you will want to 386 | # simple AnsibleModule.exit_json(), passing the key/value results 387 | module.exit_json(**result) 388 | 389 | 390 | def main(): 391 | run_module() 392 | 393 | 394 | if __name__ == '__main__': 395 | main() 396 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | deepdiff >= 5.6.0 2 | -------------------------------------------------------------------------------- /requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | collections: 3 | - name: ansible.windows 4 | -------------------------------------------------------------------------------- /roles/.git_keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/roles/.git_keep -------------------------------------------------------------------------------- /roles/install_agent/README.md: -------------------------------------------------------------------------------- 1 | install_agent 2 | ========= 3 | 4 | This Ansible role is designed to install the SentinelOne agent package and register the new endpoint in the SentinelOne Management Console. 5 | 6 | Supported Operating Systems: 7 | ------------ 8 | - Red Hat Enterprise Linux (RHEL) 9 | - 8 10 | - 9 11 | - SUSE Linux Enterprise Server (SLES) 12 | - 12 13 | - 15 14 | - Debian 15 | - 10 16 | - 11 17 | - 12 18 | - Ubuntu 19 | - 20.04 20 | - 22.04 21 | - 24.04 22 | 23 | - Windows 24 | - Server 2016 25 | - Server 2019 26 | - Server 2022 27 | - Desktop 10 28 | - Desktop 11 29 | 30 | Requirements 31 | ------------ 32 | ### API Token 33 | An API key is required to use this role. It is considered best practice to create a specific 'API user' role for this purpose. 34 | 35 | The API user requires the following permissions: 36 | - Read site info 37 | - Read group info (if the scope is set to group) 38 | - Download agent packages 39 | - Read the site or group registration token 40 | - Read agent information 41 | 42 | ### GPG Key (Linux only) 43 | You need to provide the gpg key to validate the package signatures correctly. You obtain the download link from the Sentinelone Help page: "**How to Install on a Linux Endpoint with Yum**". 44 | 45 | Place the key on the host executing the Playbook and adjust the `gpg_key` variable accordingly. 46 | 47 | Required for .deb based systems. 48 | Required for .rpm based systems when agent version >= 23.3.2.12. 49 | 50 | ### Become user 51 | **Linux:** On the control-node, where the playbook is executed, only user permissions are required. On the remote node you have to provide a become user since some tasks need to run as root. Either provide a _become_user_ on playbook or _include_role_ task scope or set the variable _ansible_become_user_ accordingly. The remote user must be configured with sudo permissions or other privilege escalation methods. 52 | 53 | **Windows:** _ansible_user_ has to be an administrator account. Therefore no privilege escalation is needed. 54 | 55 | ### Role Documentation 56 | A **[HTML documentation](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/install_agent_role.html)** in the usual Ansible documentation format can be found [here](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/install_agent_role.html). 57 | 58 | Role Variables 59 | -------------- 60 | 61 | ### Mandatory Variables 62 | 63 | | Variable | Example | Description | 64 | | --- | --- | --- | 65 | | `console_url` | https://my-console.sentinelone.net | The URL of the SentinelOne Management Console | 66 | | `api_token` | XXXXXXXXXXXXXXXXXX | The API token for the API user for authentication | 67 | | `site` | prod | The site to which the new hosts should be assigned | 68 | | `gpg_key` | /tmp/sentinel_one.gpg | **Linux** only. Required for .deb based systems. Required for .rpm based systems when agent version >= 23.3.2.12. Path to the gpg key which will be installed and used for package signature verification | 69 | 70 | ### Optional Variables 71 | 72 | | Variable | Default | Choices | Description | 73 | | --- | --- | --- | --- | 74 | | `group` | | | An optional group which is part of the site. If set, the agent will be assigned to this group instead of the 'Default Group'. | 75 | | `agent_version` | latest | latest, latest_ea, custom | Controls which agent should be installed. latest installs the latest general availability version. If custom is set, `custom_version` is mandatory | 76 | | `check_console_retries` | 3 | | How many times the ansible role tries to find the agent in the management console after installation | 77 | | `check_console_retry_delay` | 20 | | The delay in s between two attempts to find the agent in the management console | 78 | | `custom_version` | | | Install a specific version of the SentinelOne agent. Must be used in combination with `agent_version` set to 'custom' | 79 | | `hide_sensitive` | true | true, false | Hides sensitive information like API keys in module output. Only set to false for debugging purposes | 80 | | `lx_force_new_token` | false | true, false | Linux only: Set the management token on the linux agent even if it is already registered. | 81 | | `win_use_exe` | false | true, false | Windows only: By default, the .msi package is used for installation. If you prefer to use the .exe file, enable this setting | 82 | | `win_allow_reboot` | true | true, false | Windows only: After the removal of a Windows Feature (here Windows Defender) and after the agent installation, a reboot is required. The role is set to reboot at the end of the installation by default. Disable this setting if you wish to skip the reboot. | 83 | 84 | ### Variables from `vars.yml` 85 | 86 | **Note:** These variables are for documentation only. Do not override these unless you fully understand their functionality. 87 | 88 | | Variable | Description | 89 | | --- | --- | 90 | | `pkg_format` | Determines the package format (like .exe, .msi, .deb, .rpm) based on the Ansible facts | 91 | | `pkg_arch` | Sets the agent package architecture based on the Ansible facts | 92 | | `custom_os_family` | Identifies the underlying operating system (Linux or Windows) | 93 | | `api_url` | Sets the API base URL | 94 | | `agent_installed` | Determines if the agent is already installed | 95 | 96 | Dependencies 97 | ------------ 98 | 99 | If this role is used for Windows hosts, the `ansible.windows` collection needs to be installed. 100 | 101 | Example Playbook 102 | ---------------- 103 | 104 | This is an example how to use this role in your Playbooks: 105 | 106 | --- 107 | - name: Sentinelone Agent Deployment 108 | hosts: all 109 | gather_facts: true 110 | tasks: 111 | - name: "Install agent on Linux" 112 | ansible.builtin.include_role: 113 | name: sva.sentinelone.install_agent 114 | vars: 115 | console_url: "https://your-instance.sentinelone.net" 116 | api_token: "YOUR_S1_API_TOKEN" 117 | site: "ansible-test" 118 | group: "linux" # optional 119 | gpg_key: "/tmp/sentinel_one.gpg" 120 | when: ansible_facts.ansible_system is defined and ansible_facts.ansible_system == "Linux" 121 | 122 | - name: "Install specific agent version on Linux" 123 | ansible.builtin.include_role: 124 | name: sva.sentinelone.install_agent 125 | vars: 126 | console_url: "https://your-instance.sentinelone.net" 127 | api_token: "YOUR_S1_API_TOKEN" 128 | site: "ansible-test" 129 | gpg_key: "/tmp/sentinel_one.gpg" 130 | agent_version: 'custom' 131 | custom_version: '23.4.2.14' 132 | when: ansible_facts.ansible_system is defined and ansible_facts.ansible_system == "Linux" 133 | 134 | - name: "Install agent on Windows" 135 | ansible.builtin.include_role: 136 | name: sva.sentinelone.install_agent 137 | vars: 138 | console_url: "https://your-instance.sentinelone.net" 139 | api_token: "YOUR_S1_API_TOKEN" 140 | site: "ansible-test" 141 | group: "windows" # optional 142 | win_use_exe: true # optional 143 | win_allow_reboot: false # optional 144 | when: os_family == "Windows" 145 | 146 | 147 | License 148 | ------- 149 | 150 | This SVA SentinelOne install_agent role is licensed under the GNU General Public License v3.0+. You can view the complete license text [here](../../LICENSE). 151 | 152 | Author Information 153 | ------------------ 154 | 155 | - Marco Wester (@mwester117) 156 | -------------------------------------------------------------------------------- /roles/install_agent/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for install_agent 3 | win_use_exe: false 4 | win_allow_reboot: true 5 | agent_version: 'latest' 6 | hide_sensitive: true 7 | lx_force_new_token: false 8 | check_console_retries: 3 9 | check_console_retry_delay: 20 10 | # custom_version, console_url, api_token, site, group 11 | -------------------------------------------------------------------------------- /roles/install_agent/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # handlers file for install_agent 3 | -------------------------------------------------------------------------------- /roles/install_agent/meta/argument_specs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | argument_specs: 3 | # roles/install_agent/tasks/main.yml entry point 4 | main: 5 | short_description: "Entrypoint for install_agent role" 6 | description: 7 | - "This is the main entrypoint for the C(install_agent) role." 8 | - "The entrypoint contains all os independent tasks and prepares the environment 9 | for the os specific tasks." 10 | author: 11 | - Marco Wester 12 | options: 13 | win_use_exe: 14 | type: "bool" 15 | required: false 16 | default: false 17 | description: 18 | - "Windows only: Controls the Windows agent package format." 19 | - "B(false:) Downloads .msi installer file" 20 | - "B(true:) Downloads .exe installer file" 21 | choices: 22 | - true 23 | - false 24 | 25 | win_allow_reboot: 26 | type: "bool" 27 | required: false 28 | default: true 29 | description: 30 | - "Windows only: Endpoint needs to be rebooted to SentinelOne work properly." 31 | - "Set to B(false) to disable automatic reboot" 32 | choices: 33 | - true 34 | - false 35 | 36 | agent_version: 37 | type: "str" 38 | required: false 39 | default: "latest" 40 | description: 41 | - "The agent version string. B(default) is to install 'latest' version." 42 | - "B(latest:) Install latest GA version." 43 | - "B(latest_ea:) Install latest EA version." 44 | - "B(custom): Install custom agent version. If set to custom argument 'custom_version' is required." 45 | choices: 46 | - "latest" 47 | - "latest_ea" 48 | - "custom" 49 | 50 | hide_sensitive: 51 | type: "bool" 52 | required: false 53 | default: true 54 | description: 55 | - "Hide sensitive information like API keys in module output." 56 | - "Only change to false for debugging purposes." 57 | choices: 58 | - true 59 | - false 60 | 61 | lx_force_new_token: 62 | type: "bool" 63 | required: false 64 | default: false 65 | description: "Linux only: Set the management token on the linux agent even if it is already registered." 66 | choices: 67 | - true 68 | - false 69 | 70 | check_console_retries: 71 | type: "int" 72 | required: false 73 | default: 3 74 | description: "How many times the ansible role tries to find the agent in the management console after installation" 75 | 76 | check_console_retry_delay: 77 | type: "int" 78 | required: false 79 | default: 20 80 | description: "The delay in s between two attempts to find the agent in the management console after installation" 81 | 82 | custom_version: 83 | type: "str" 84 | required: false 85 | description: 86 | - "Required when 'agent_version' is set to B(custom)." 87 | - "Explicit version of the agent to be installed." 88 | 89 | console_url: 90 | type: "str" 91 | required: true 92 | description: "The SentinelOne management console URL. E.g. https://my-s1-console.net." 93 | 94 | api_token: 95 | type: "str" 96 | required: true 97 | description: "The API token to authenticate at the management console." 98 | 99 | site: 100 | type: "str" 101 | required: true 102 | description: "The name of the site where the new endpoint should join." 103 | 104 | group: 105 | type: "str" 106 | required: false 107 | description: "Optional: The name of the group where the new endpoint should join." 108 | 109 | gpg_key: 110 | type: "str" 111 | required: false 112 | description: 113 | - "The path to the gpg key to verify the agent package integrity. Required if installing a rpm agent package >= 23.3.2.12" 114 | -------------------------------------------------------------------------------- /roles/install_agent/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | author: Marco Wester 4 | description: This role installs the SentinelOne agent package on Linux or Windows endpoints 5 | company: Systemvertrieb Alexander GmbH (SVA) 6 | 7 | # If the issue tracker for your role is not on github, uncomment the 8 | # next line and provide a value 9 | issue_tracker_url: "https://github.com/svalabs/sva.sentinelone/issues" 10 | 11 | # Choose a valid license ID from https://spdx.org - some suggested licenses: 12 | # - BSD-3-Clause (default) 13 | # - MIT 14 | # - GPL-2.0-or-later 15 | # - GPL-3.0-only 16 | # - Apache-2.0 17 | # - CC-BY-4.0 18 | license: GPL-3.0-or-later 19 | 20 | min_ansible_version: "2.10" 21 | 22 | # If this a Container Enabled role, provide the minimum Ansible Container version. 23 | # min_ansible_container_version: 24 | 25 | # 26 | # Provide a list of supported platforms, and for each platform a list of versions. 27 | # If you don't wish to enumerate all versions for a particular platform, use 'all'. 28 | # To view available platforms and versions (or releases), visit: 29 | # https://galaxy.ansible.com/api/v1/platforms/ 30 | # 31 | platforms: 32 | - name: Ubuntu 33 | - name: Fedora 34 | - name: Debian 35 | - name: Rocky 36 | - name: SLES 37 | - name: EL 38 | - name: Windows 39 | 40 | galaxy_tags: 41 | - sentinelone 42 | - agent 43 | - install 44 | - installagent 45 | - linux 46 | - windows 47 | - rhel 48 | - el 49 | - sles 50 | - suse 51 | - debian 52 | - ubuntu 53 | # List tags for your role here, one per line. A tag is a keyword that describes 54 | # and categorizes the role. Users find roles by searching for tags. Be sure to 55 | # remove the '[]' above, if you add tags to this list. 56 | # 57 | # NOTE: A tag is limited to a single word comprised of alphanumeric characters. 58 | # Maximum 20 tags per role. 59 | 60 | dependencies: [] 61 | # List your role dependencies here, one per line. Be sure to remove the '[]' above, 62 | # if you add dependencies to this list. 63 | -------------------------------------------------------------------------------- /roles/install_agent/tasks/Linux.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Linux: Include Linux.yml vars" 3 | ansible.builtin.include_vars: 4 | file: Linux.yml 5 | 6 | - name: "Linux Block: Skip if sentinelagent is already installed." 7 | when: not agent_installed 8 | become: true 9 | block: 10 | - name: Get dmesg output 11 | ansible.builtin.command: dmesg 12 | changed_when: false 13 | register: dmesg_output 14 | 15 | - name: Assert that no function tracing issues occured 16 | ansible.builtin.assert: 17 | that: 18 | - "'FUNCTION TRACING IS CORRUPTED' not in dmesg_output.stdout" 19 | fail_msg: 'System instability detected, function tracing seems corrupted' 20 | 21 | - name: "Linux: Copy agent package to remote server" 22 | ansible.builtin.copy: 23 | src: "{{ return_download_agent.original_message.full_path }}" 24 | dest: "{{ remote_pkg_path }}" 25 | mode: "0644" 26 | 27 | - name: "Block: RPM based systems" 28 | when: pkg_format == "rpm" 29 | block: 30 | 31 | - name: "Linux: Copy gpg key to remote server" 32 | ansible.builtin.copy: 33 | src: "{{ gpg_key }}" 34 | dest: "{{ remote_gpg_key_path }}" 35 | mode: "0644" 36 | when: signed_package 37 | 38 | - name: "Linux: Import GPG key for rpm" 39 | ansible.builtin.rpm_key: 40 | key: "{{ remote_gpg_key_path }}" 41 | when: signed_package 42 | 43 | - name: "Linux: Install unsigned .rpm agent package via rpm" 44 | ansible.builtin.command: 45 | cmd: "rpm -i --nodigest {{ remote_pkg_path }}" 46 | creates: "/opt/sentinelone/bin/sentinelctl" 47 | when: not signed_package 48 | 49 | - name: "Linux: Install signed .rpm agent package {{ remote_pkg_path }}" 50 | ansible.builtin.package: 51 | name: "{{ remote_pkg_path }}" 52 | when: signed_package 53 | 54 | - name: "Block: DEB based systems" 55 | when: pkg_format == "deb" 56 | block: 57 | - name: "Install gpg" 58 | ansible.builtin.apt: 59 | name: gpg 60 | update_cache: true 61 | 62 | - name: "Linux: Copy gpg key to remote server" 63 | ansible.builtin.copy: 64 | src: "{{ gpg_key }}" 65 | dest: "{{ remote_gpg_key_path }}" 66 | mode: "0644" 67 | 68 | - name: "Linux: Import GPG key for apt" 69 | ansible.builtin.apt_key: 70 | file: "{{ remote_gpg_key_path }}" 71 | 72 | - name: "Linux: Install deb agent package {{ remote_pkg_path }}" 73 | ansible.builtin.apt: 74 | deb: "{{ remote_pkg_path }}" 75 | 76 | - name: "Linux: Check if agent is already registered" 77 | ansible.builtin.shell: 78 | # \\s needed because yaml interprets \s as escape sequence 79 | cmd: "set -o pipefail && /opt/sentinelone/bin/sentinelctl management status | grep -E '^Connectivity\\s+(On|Off)$' | awk '{ print $2 }'" 80 | executable: "/bin/bash" 81 | become: true 82 | register: agent_status 83 | failed_when: agent_status.stdout is not regex("On|Off") 84 | changed_when: agent_status.stdout is not regex("On|Off") 85 | 86 | - name: "Linux: Register agent" 87 | ansible.builtin.command: 88 | cmd: '/opt/sentinelone/bin/sentinelctl management token set {{ reg_token_obj.json.data.token }}' 89 | become: true 90 | register: token_set_output 91 | no_log: "{{ hide_sensitive }}" 92 | failed_when: '"Registration token successfully set" not in token_set_output.stdout' 93 | when: agent_status.stdout == 'Off' or lx_force_new_token 94 | 95 | - name: "Linux: Start and enable service 'sentinelone' if neccessary" 96 | ansible.builtin.service: 97 | name: sentinelone 98 | state: started 99 | enabled: true 100 | become: true 101 | 102 | - name: "Linux: Remove agent install package from target machine" 103 | ansible.builtin.file: 104 | path: "{{ remote_pkg_path }}" 105 | state: absent 106 | become: true 107 | when: not agent_installed 108 | 109 | - name: "Linux: Remove gpg key from target machine" 110 | ansible.builtin.file: 111 | path: "{{ remote_gpg_key_path }}" 112 | state: absent 113 | become: true 114 | -------------------------------------------------------------------------------- /roles/install_agent/tasks/Windows.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "Windows: Include Windows.yml vars" 3 | ansible.builtin.include_vars: 4 | file: Windows.yml 5 | 6 | - name: "Windows: Remove windows defender feature on Windows Server OS" 7 | ansible.windows.win_feature: 8 | name: Windows-Defender 9 | state: absent 10 | register: win_feature_remove 11 | when: "'Windows Server' in ansible_os_name" 12 | 13 | - name: "Block: Windows: Install SentinelOne agent" 14 | when: not agent_installed 15 | block: 16 | - name: "Windows: Copy agent package to remote server" 17 | ansible.windows.win_copy: 18 | src: "{{ return_download_agent.original_message.full_path }}" 19 | dest: "{{ remote_pkg_path }}" 20 | mode: "0644" 21 | 22 | - name: "Windows: Install agent package {{ return_download_agent.original_message.filename }}" 23 | ansible.windows.win_package: 24 | path: "{{ remote_pkg_path }}" 25 | creates_service: "SentinelAgent" 26 | arguments: "{{ exe_parameters if pkg_format == 'exe' else msi_parameters }}" 27 | register: installation_result 28 | no_log: "{{ hide_sensitive }}" 29 | when: pkg_format == "msi" or pkg_format == "exe" 30 | 31 | - name: "Windows: Wait for 15 seconds" 32 | ansible.builtin.pause: 33 | seconds: 15 34 | 35 | - name: "Windows: Remove agent package from target machine" 36 | ansible.windows.win_file: 37 | path: "{{ remote_pkg_path }}" 38 | state: absent 39 | 40 | - name: "Windows: Get service information" 41 | ansible.windows.win_service_info: 42 | name: SentinelAgent 43 | register: sentinelagent_postinstall_service 44 | 45 | - name: "Windows: Fail when agent not started and/or not in auto start mode" 46 | ansible.builtin.fail: "Sentinelone Agent is not started and/or not configured for automatic start. Service: {{ sentinelagent_postinstall_service }}" 47 | when: sentinelagent_postinstall_service.services[0].state != "started" or sentinelagent_postinstall_service.services[0].start_mode != "auto" 48 | 49 | - name: "Windos: Reboot after agent installation or Windows Feature removal" 50 | ansible.windows.win_reboot: 51 | post_reboot_delay: 60 52 | when: (installation_result.changed | default(false) or win_feature_remove.reboot_required) and win_allow_reboot 53 | -------------------------------------------------------------------------------- /roles/install_agent/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # tasks file for install_agent 3 | - name: "Gather facts" 4 | ansible.builtin.setup: 5 | when: ansible_facts.distribution is not defined 6 | 7 | - name: "Preflight check: Fail when OS is unknown" 8 | ansible.builtin.fail: 9 | msg: "The package management system on this system is unknown. This can happen if the role runs on an unsupported os." 10 | when: pkg_format == 'unknown' 11 | 12 | - name: "Linux: Install python bindings for package managers" 13 | ansible.builtin.package: 14 | name: "{{ 'python3-rpm' if ansible_facts.os_family == 'Suse' else 'python-apt-common' }}" 15 | become: true 16 | when: ansible_facts.os_family == 'Suse' or ansible_facts.os_family == 'Debian' 17 | 18 | - name: "Linux: Gather package facts" 19 | ansible.builtin.package_facts: 20 | when: ansible_facts.packages is not defined and custom_os_family == 'Linux' 21 | 22 | - name: "Windows: Check if SentinelOne is already installed" 23 | ansible.windows.win_service_info: 24 | name: SentinelAgent 25 | register: sentinelagent_service 26 | when: custom_os_family == 'Windows' 27 | 28 | - name: "Set fact: agent_installed" 29 | ansible.builtin.set_fact: 30 | agent_installed: "{{ true if ansible_facts.packages.sentinelagent is defined or ansible_facts.packages.SentinelAgent is defined 31 | or sentinelagent_service.exists | default(false) else false }}" 32 | 33 | - name: "Download agent to localhost. Version: {{ agent_version }}" 34 | sva.sentinelone.sentinelone_download_agent: 35 | console_url: "{{ console_url }}" 36 | token: "{{ api_token }}" 37 | site: "{{ site }}" 38 | os_type: "{{ custom_os_family }}" 39 | packet_format: "{{ pkg_format }}" 40 | architecture: "{{ pkg_arch }}" 41 | agent_version: "{{ agent_version }}" 42 | custom_version: "{{ custom_version | default(omit) }}" 43 | register: return_download_agent 44 | delegate_to: localhost 45 | throttle: 1 46 | when: not agent_installed 47 | 48 | - name: "Block: Get registration token from API" 49 | run_once: true 50 | block: 51 | - name: "Get siteid" 52 | ansible.builtin.uri: 53 | url: "{{ api_url }}sites?name={{ site | urlencode }}&state=active" 54 | method: GET 55 | return_content: true 56 | headers: 57 | Accept: application/json 58 | Authorization: "APIToken {{ api_token }}" 59 | validate_certs: true 60 | status_code: 200 61 | register: siteobj 62 | delegate_to: localhost 63 | no_log: "{{ hide_sensitive }}" 64 | until: ((siteobj.json.data.sites | length) > 0) and (siteobj.status == 200) 65 | retries: 3 66 | delay: 20 67 | 68 | - name: "Extract siteid" 69 | ansible.builtin.set_fact: 70 | siteid: "{{ siteobj.json.data.sites[0].id }}" 71 | 72 | - name: "Get group id" 73 | ansible.builtin.uri: 74 | url: "{{ api_url }}groups?name={{ group | urlencode }}&siteIds={{ siteid }}" 75 | method: GET 76 | return_content: true 77 | headers: 78 | Accept: application/json 79 | Authorization: "APIToken {{ api_token }}" 80 | validate_certs: true 81 | status_code: 200 82 | register: groupobj 83 | delegate_to: localhost 84 | no_log: "{{ hide_sensitive }}" 85 | until: ((groupobj.json.data | length) > 0) and (groupobj.status == 200) 86 | retries: 3 87 | delay: 20 88 | when: group is defined 89 | 90 | - name: "Extract groupid" 91 | ansible.builtin.set_fact: 92 | groupid: "{{ groupobj.json.data[0].id }}" 93 | when: group is defined 94 | 95 | - name: "Set endpoint URI to get the correct registration token" 96 | ansible.builtin.set_fact: 97 | reg_token_uri: "{{ \"groups/{{ groupid }}/token\" if group is defined else \"sites/{{ siteid }}/token\" }}" 98 | 99 | - name: "Get registration token" 100 | ansible.builtin.uri: 101 | url: "{{ api_url }}{{ reg_token_uri }}" 102 | method: GET 103 | return_content: true 104 | headers: 105 | Accept: application/json 106 | Authorization: "APIToken {{ api_token }}" 107 | validate_certs: true 108 | status_code: 200 109 | register: reg_token_obj 110 | delegate_to: localhost 111 | no_log: "{{ hide_sensitive }}" 112 | until: reg_token_obj.status == 200 113 | retries: 3 114 | delay: 20 115 | 116 | - name: "Include tasks for: {{ custom_os_family }}" 117 | ansible.builtin.include_tasks: "{{ custom_os_family }}.yml" 118 | 119 | - name: "Remove agent install package from localhost" 120 | ansible.builtin.file: 121 | path: "{{ return_download_agent.original_message.full_path }}" 122 | state: absent 123 | delegate_to: localhost 124 | when: not agent_installed 125 | 126 | - name: "Fail if new client does not appear in management console" 127 | ansible.builtin.uri: 128 | url: "{{ api_url }}agents?siteIds={{ siteid }}&computerName={{ ansible_hostname | urlencode }}&isActive=true" 129 | method: GET 130 | return_content: true 131 | headers: 132 | Accept: application/json 133 | Authorization: "APIToken {{ api_token }}" 134 | validate_certs: true 135 | status_code: 200 136 | register: registrationstatus 137 | delegate_to: localhost 138 | no_log: "{{ hide_sensitive }}" 139 | until: ((registrationstatus.json.data | length) > 0) and (registrationstatus.status == 200) 140 | retries: "{{ check_console_retries }}" 141 | delay: "{{ check_console_retry_delay }}" 142 | -------------------------------------------------------------------------------- /roles/install_agent/tests/inventory: -------------------------------------------------------------------------------- 1 | localhost 2 | 3 | -------------------------------------------------------------------------------- /roles/install_agent/tests/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Test role 3 | hosts: localhost 4 | remote_user: root 5 | roles: 6 | - install_agent 7 | -------------------------------------------------------------------------------- /roles/install_agent/vars/Linux.yml: -------------------------------------------------------------------------------- 1 | --- 2 | remote_pkg_path: "/tmp/{{ return_download_agent.original_message.filename }}" 3 | remote_gpg_key_path: "/tmp/sentinel_one.gpg" 4 | signed_package: "{{ false if agent_version == 'custom' and custom_version is version('23.3.2.12', '<') else true }}" 5 | -------------------------------------------------------------------------------- /roles/install_agent/vars/Windows.yml: -------------------------------------------------------------------------------- 1 | --- 2 | remote_pkg_path: "C:\\Windows\\Temp\\{{ return_download_agent.original_message.filename }}" 3 | exe_parameters: "-t {{ reg_token_obj.json.data.token }} -q" 4 | msi_parameters: "SITE_TOKEN={{ reg_token_obj.json.data.token }} /QUIET" 5 | -------------------------------------------------------------------------------- /roles/install_agent/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for install_agent 3 | pkg_format: "{% if ansible_facts.pkg_mgr | default([]) in ['yum', 'dnf', 'zypper'] %}rpm{% 4 | elif ansible_facts.pkg_mgr | default('') == 'apt' %}deb{% 5 | elif ansible_facts.os_family | default('') == 'Windows' and not win_use_exe %}msi{% 6 | elif ansible_facts.os_family | default('') == 'Windows' and win_use_exe %}exe{% 7 | else %}unknown{% endif %}" 8 | pkg_arch: "{% if ansible_facts.architecture | regex_search('x86_64|64-bit') %}64_bit{% 9 | elif ansible_facts.architecture | regex_search('i386|32-bit') %}32_bit{% 10 | elif ansible_facts.architecture == 'aarch64' %}aarch64{% 11 | else %}unknown{% endif %}" 12 | custom_os_family: "{{ 'Windows' if pkg_format | regex_search('msi|exe') else 'Linux' }}" 13 | api_url: "{{ console_url }}/web/api/v2.1/" 14 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/.config/ansible-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | skip_list: 3 | - fqcn-builtins 4 | - command-instead-of-module 5 | - no-changed-when 6 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/.gitignore: -------------------------------------------------------------------------------- 1 | *.rpm 2 | *.deb 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | "MD013": false # line-length 3 | "MD014": false # show commands output 4 | "MD024": false # duplicate headings 5 | "MD025": false # multiple top-level headings in the same document 6 | "MD026": false # no trailing punction (? at the end) 7 | "MD033": false # inline HTML 8 | "MD036": true # no emphasis as heading (command descriptions) 9 | "MD041": false # leading comments (beginning not heading) 10 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: "2.7" 4 | 5 | # Use the new container infrastructure 6 | sudo: false 7 | 8 | # Install ansible 9 | addons: 10 | apt: 11 | packages: 12 | - python-pip 13 | 14 | install: 15 | # Install ansible 16 | - pip install ansible 17 | 18 | # Check ansible version 19 | - ansible --version 20 | 21 | # Create ansible.cfg with correct roles_path 22 | - printf '[defaults]\nroles_path=../' >ansible.cfg 23 | 24 | script: 25 | # Basic role syntax check 26 | - ansible-playbook tests/test.yml -i tests/inventory --syntax-check 27 | 28 | notifications: 29 | webhooks: https://galaxy.ansible.com/api/v1/notifications/ 30 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | # Based on ansible-lint config 3 | extends: default 4 | 5 | rules: 6 | braces: 7 | max-spaces-inside: 1 8 | level: error 9 | brackets: 10 | max-spaces-inside: 1 11 | level: error 12 | colons: 13 | max-spaces-after: -1 14 | level: error 15 | commas: 16 | max-spaces-after: -1 17 | level: error 18 | comments: disable 19 | comments-indentation: disable 20 | document-start: disable 21 | empty-lines: 22 | max: 3 23 | level: error 24 | hyphens: 25 | level: error 26 | indentation: disable 27 | key-duplicates: enable 28 | line-length: disable 29 | new-line-at-end-of-file: disable 30 | new-lines: 31 | type: unix 32 | trailing-spaces: disable 33 | truthy: disable 34 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/README.md: -------------------------------------------------------------------------------- 1 | # sentinelone_client_legacy 2 | 3 | **This role was merged to this collection from the Ansible role [sentinelone_client](https://github.com/stdevel/ansible-sentinelone_client) by [@stdevel](https://github.com/stdevel).** 4 | 5 | For greater flexibility, it's recommended to use the install_agent role if you have access to both the management console and an API access token. However, if you don't have console access and need to install the agent packages from an alternate source, this role is designed for that scenario. Please note that the agent package must be accessible via a web server to use this role. 6 | 7 | Installs and registers the SentinelOne Endpoint agent with provided os packages (linux only). 8 | 9 | ## Requirements 10 | 11 | No requirements. 12 | 13 | ## Role Variables 14 | 15 | | Variable | Default | Description | 16 | | ------------------------------------------------- | --------- | -------------------------------- | 17 | | `sentinelone_client_filename` | *(empty)* | Package file to install | 18 | | `sentinelone_client_token` | *(empty)* | Group/Site token | 19 | | `sentinelone_client_gpgkey` | *(empty)* | GPG signing key to import | 20 | | `sentinelone_client_force_new_token` | `false` | Set to true to force a new token | 21 | | `sentinelone_client_customer_id` | *(empty)* | Set optional customer id | 22 | | `sentinelone_client_zypper_disable_gpg_check` | `false` | Disable GPG Check for zypper | 23 | 24 | ## Dependencies 25 | 26 | No dependencies. 27 | 28 | ### Role Documentation 29 | A **[HTML documentation](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_client_legacy_role.html)** in the usual Ansible documentation format can be found [here](https://svalabs.github.io/sva.sentinelone/branch/main/collections/sva/sentinelone/sentinelone_client_legacy_role.html). 30 | 31 | ## Example Playbook 32 | 33 | ```yml 34 | - hosts: clients 35 | roles: 36 | - role: sva.sentinelone.sentinelone_client_legacy 37 | sentinelone_client_filename: SentinelAgent_linux_v21_10_3_3.rpm 38 | sentinelone_client_token: trustno1 39 | ``` 40 | 41 | Repository installation: 42 | 43 | ```yml 44 | - hosts: clients 45 | roles: 46 | - role: sva.sentinelone.sentinelone_client_legacy 47 | sentinelone_client_filename: https://simone.giertz.dev/SentinelAgent_linux_v13_37.deb 48 | sentinelone_client_token: trustno1 49 | ``` 50 | 51 | ## Development / testing 52 | 53 | Use [Ansible Molecule](https://molecule.readthedocs.io/en/latest/index.html) for running tests: 54 | 55 | ```shell 56 | $ molecule create 57 | $ molecule converge 58 | $ molecule verify 59 | ``` 60 | 61 | ## License 62 | 63 | BSD 64 | 65 | ## Author Information 66 | 67 | Christian Stankowic 68 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sentinelone_client_token: '' 3 | sentinelone_client_gpgkey: '' 4 | sentinelone_client_force_new_token: false 5 | sentinelone_client_customer_id: '' 6 | sentinelone_client_zypper_disable_gpg_check: 'false' 7 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create initialization file 3 | ansible.builtin.file: 4 | path: /opt/sentinelone/.INITIALIZATION_COMPLETE 5 | owner: root 6 | group: root 7 | mode: '0644' 8 | state: touch 9 | become: true 10 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/meta/argument_specs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | argument_specs: 3 | # roles/sentinelone_client_legacy/tasks/main.yml entry point 4 | main: 5 | short_description: "Entrypoint for sentinelone_client_legacy role" 6 | version_added: 2.0.0 7 | description: 8 | - "This is the main entrypoint for the C(sentinelone_client_legacy) role." 9 | - "The entrypoint contains all os independent tasks and prepares the environment 10 | for the os specific tasks." 11 | author: 12 | - Christian Stankovic 13 | options: 14 | sentinelone_client_filename: 15 | type: "str" 16 | required: true 17 | description: 18 | - "Package file to install" 19 | 20 | sentinelone_client_token: 21 | type: "str" 22 | required: true 23 | description: 24 | - "Group/Site token" 25 | 26 | sentinelone_client_gpgkey: 27 | type: "str" 28 | required: false 29 | description: 30 | - "GPG signing key to import" 31 | 32 | sentinelone_client_force_new_token: 33 | type: "bool" 34 | required: false 35 | default: false 36 | description: 37 | - "Set to true to force a new token" 38 | 39 | sentinelone_client_customer_id: 40 | type: "str" 41 | required: false 42 | default: '' 43 | description: 44 | - "Set optional customer id" 45 | 46 | entinelone_client_zypper_disable_gpg_check: 47 | type: "bool" 48 | required: false 49 | default: false 50 | description: 51 | - "Disable GPG Check for zypper" 52 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | role_name: sentinelone_client_legacy 4 | author: Christian Stankowic 5 | description: Installs the SentinelOne agent on linux 6 | license: GPL-3.0-or-later 7 | 8 | min_ansible_version: '2.10' 9 | 10 | # If this a Container Enabled role, provide the minimum Ansible Container version. 11 | # min_ansible_container_version: 12 | 13 | # 14 | # Provide a list of supported platforms, and for each platform a list of versions. 15 | # If you don't wish to enumerate all versions for a particular platform, use 'all'. 16 | # To view available platforms and versions (or releases), visit: 17 | # https://galaxy.ansible.com/api/v1/platforms/ 18 | # 19 | platforms: 20 | - name: Ubuntu 21 | - name: Fedora 22 | - name: Debian 23 | - name: EL 24 | versions: 25 | - '8' 26 | 27 | 28 | galaxy_tags: 29 | - sentinelone 30 | - sentinel 31 | - antivirus 32 | - legacy 33 | 34 | dependencies: [] 35 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/roles/sentinelone_client_legacy/molecule/.DS_Store -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/INSTALL.rst: -------------------------------------------------------------------------------- 1 | ********************************* 2 | Vagrant driver installation guide 3 | ********************************* 4 | 5 | Requirements 6 | ============ 7 | 8 | * Vagrant 9 | * Virtualbox, Parallels, VMware Fusion, VMware Workstation or VMware Desktop 10 | 11 | Install 12 | ======= 13 | 14 | Please refer to the `Virtual environment`_ documentation for installation best 15 | practices. If not using a virtual environment, please consider passing the 16 | widely recommended `'--user' flag`_ when invoking ``pip``. 17 | 18 | .. _Virtual environment: https://virtualenv.pypa.io/en/latest/ 19 | .. _'--user' flag: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site 20 | 21 | .. code-block:: bash 22 | 23 | $ pip install 'molecule_vagrant' 24 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | In order to test the role you'll need Ansible, Molecule and a supported provider such as Vagrant. 4 | 5 | If you also want to test registration, add the following line to [`converge.yml`](converge.yml): 6 | 7 | ```yml 8 | sentinelone_client_token: "..." 9 | ``` 10 | 11 | Copy the SentinelONE installation files (`sentinelone_latest.deb`, `sentinelone_latest.rpm`) into this directory and run `molecule`: 12 | 13 | ```shell 14 | $ molecule create 15 | $ molecule converge 16 | ``` 17 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | pre_tasks: 5 | - name: Set SentinelONE client installation file (Debian) 6 | ansible.builtin.set_fact: 7 | file_sentinelone: sentinelone_latest.deb 8 | when: ansible_os_family == 'Debian' 9 | 10 | - name: Set SentinelONE client installation file (Red Hat) 11 | ansible.builtin.set_fact: 12 | file_sentinelone: sentinelone_latest.rpm 13 | when: ansible_os_family == 'RedHat' 14 | 15 | - name: Set SentinelONE client installation file (SUSE) 16 | ansible.builtin.set_fact: 17 | file_sentinelone: sentinelone_latest.rpm 18 | when: ansible_os_family == 'Suse' 19 | 20 | roles: 21 | - role: sva.sentinelone.sentinelone_client_legacy 22 | sentinelone_client_filename: "{{ file_sentinelone }}" 23 | # sentinelone_client_token: '...' 24 | # sentinelone_client_gpgkey: '...' 25 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | driver: 5 | name: vagrant 6 | platforms: 7 | - name: s1-ubuntu 8 | box: generic/ubuntu2204 9 | - name: s1-fedora 10 | box: generic/fedora38 11 | - name: s1-almalinux 12 | box: almalinux/9 13 | - name: s1-opensuse 14 | box: opensuse/Tumbleweed.x86_64 15 | provisioner: 16 | name: ansible 17 | verifier: 18 | name: testinfra 19 | lint: | 20 | yamllint . 21 | ansible-lint 22 | flake8 23 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """PyTest Fixtures.""" 2 | from __future__ import absolute_import 3 | 4 | import os 5 | 6 | import pytest 7 | 8 | 9 | def pytest_runtest_setup(item): 10 | """Run tests only when under molecule with testinfra installed.""" 11 | try: 12 | import testinfra 13 | except ImportError: 14 | pytest.skip("Test requires testinfra", allow_module_level=True) 15 | if "MOLECULE_INVENTORY_FILE" in os.environ: 16 | pytest.testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( 17 | os.environ["MOLECULE_INVENTORY_FILE"] 18 | ).get_hosts("all") 19 | else: 20 | pytest.skip( 21 | "Test should run only from inside molecule.", 22 | allow_module_level=True 23 | ) 24 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/molecule/default/tests/test_default.py: -------------------------------------------------------------------------------- 1 | """ 2 | Role unit tests 3 | """ 4 | 5 | 6 | def test_packages(host): 7 | """ 8 | Ensure that packages are installed 9 | """ 10 | os = host.ansible("setup")["ansible_facts"]["ansible_os_family"].lower() 11 | if os == "debian": 12 | pkg = 'sentinelagent' 13 | else: 14 | pkg = 'SentinelAgent' 15 | assert host.package(pkg).is_installed 16 | 17 | 18 | def test_service(host): 19 | """ 20 | Ensure that service is enabled and running 21 | """ 22 | srv = 'sentinelone.service' 23 | _srv = host.service(srv) 24 | assert _srv.is_enabled 25 | assert _srv.is_running 26 | 27 | 28 | def test_registration(host): 29 | """ 30 | Ensure that registration has succeeded 31 | """ 32 | with host.sudo(): 33 | cmd = host.run( 34 | "sentinelctl management status" 35 | ).stdout.strip().split("\n") 36 | # check that URL and UUID are not undefined 37 | _url = [x for x in cmd if "URL" in x] 38 | _uuid = [x for x in cmd if "UUID" in x] 39 | _connect = [x for x in cmd if "Connectivity" in x] 40 | assert "undefined" not in _url[0] 41 | assert "undefined" not in _uuid[0] 42 | assert "Off" not in _connect[0] 43 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tasks/digest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Gather RPM package version 3 | ansible.builtin.command: "rpm -qp --queryformat '%{VERSION}' /tmp/{{ sentinelone_client_filename | basename }}" 4 | register: sentinelone_client_rpm_version 5 | changed_when: false 6 | 7 | - name: Set nodigest flag, if required 8 | ansible.builtin.set_fact: 9 | sentinelone_client_digest: '--nodigest' 10 | when: 11 | - "sentinelone_client_rpm_version.stdout is version('23.3.2.12', '<')" 12 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tasks/install_debian.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Import GPG key 3 | ansible.builtin.apt_key: 4 | url: "{{ sentinelone_client_gpgkey }}" 5 | become: true 6 | when: sentinelone_client_gpgkey 7 | 8 | - name: Install package 9 | ansible.builtin.apt: 10 | deb: "/tmp/{{ sentinelone_client_filename | basename }}" 11 | update_cache: true 12 | become: true 13 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tasks/install_redhat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Import GPG key 3 | ansible.builtin.rpm_key: 4 | key: "{{ sentinelone_client_gpgkey }}" 5 | become: true 6 | when: sentinelone_client_gpgkey 7 | 8 | - name: Include digest tasks 9 | ansible.builtin.include_tasks: digest.yml 10 | 11 | - name: Install package (digest) 12 | ansible.builtin.command: "rpm -ivh --nodigest /tmp/{{ sentinelone_client_filename | basename }}" 13 | register: rpmout 14 | changed_when: 15 | - "'Updating / installing' in rpmout.stdout" 16 | failed_when: 17 | - rpmout.failed 18 | - "'is already installed' not in rpmout.stderr" 19 | ignore_errors: true 20 | become: true 21 | when: sentinelone_client_digest is defined 22 | 23 | - name: Install package 24 | ansible.builtin.yum: 25 | name: "/tmp/{{ sentinelone_client_filename | basename }}" 26 | become: true 27 | when: sentinelone_client_digest is not defined 28 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tasks/install_suse.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Import GPG key 3 | ansible.builtin.rpm_key: 4 | key: "{{ sentinelone_client_gpgkey }}" 5 | become: true 6 | when: sentinelone_client_gpgkey 7 | 8 | - name: Include digest tasks 9 | ansible.builtin.include_tasks: digest.yml 10 | 11 | - name: Install package (digest) 12 | ansible.builtin.command: "rpm -ivh --nodigest /tmp/{{ sentinelone_client_filename | basename }}" 13 | register: rpmout 14 | changed_when: 15 | - "'Updating / installing' in rpmout.stdout" 16 | failed_when: 17 | - rpmout.failed 18 | - "'is already installed' not in rpmout.stderr" 19 | ignore_errors: true 20 | become: true 21 | when: sentinelone_client_digest is defined 22 | 23 | - name: Install package 24 | community.general.zypper: 25 | name: "/tmp/{{ sentinelone_client_filename | basename }}" 26 | disable_gpg_check: "{{ sentinelone_client_zypper_disable_gpg_check }}" 27 | become: true 28 | when: sentinelone_client_digest is not defined 29 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include distribution-specific variables 3 | ansible.builtin.include_vars: "{{ ansible_os_family | regex_replace(' ', '_') | lower }}.yml" 4 | 5 | - name: Get dmesg output 6 | ansible.builtin.command: dmesg 7 | changed_when: false 8 | become: true 9 | register: dmesg_output 10 | 11 | - name: Assert that no function tracing issues occured 12 | ansible.builtin.assert: 13 | that: 14 | - "'FUNCTION TRACING IS CORRUPTED' not in dmesg_output.stdout" 15 | fail_msg: 'System instability detected, function tracing seems corrupted' 16 | 17 | - name: Download installation package 18 | ansible.builtin.get_url: 19 | url: "{{ sentinelone_client_filename }}" 20 | dest: "/tmp/{{ sentinelone_client_filename | basename }}" 21 | mode: "0644" 22 | when: "'http' in sentinelone_client_filename" 23 | 24 | - name: Copy installation package 25 | ansible.builtin.copy: 26 | src: "{{ sentinelone_client_filename }}" 27 | dest: "/tmp/{{ sentinelone_client_filename | basename }}" 28 | mode: '0644' 29 | when: "'http' not in sentinelone_client_filename" 30 | 31 | - name: Include installation tasks 32 | ansible.builtin.include_tasks: "install_{{ ansible_os_family | regex_replace(' ', '_') | lower }}.yml" 33 | 34 | - name: Remove installation package 35 | ansible.builtin.file: 36 | path: "/tmp/{{ sentinelone_client_filename | basename }}" 37 | state: absent 38 | 39 | - name: Check if we need to force a new token 40 | ansible.builtin.file: 41 | path: /opt/sentinelone/.INITIALIZATION_COMPLETE 42 | state: absent 43 | become: true 44 | when: 45 | - sentinelone_client_force_new_token 46 | - sentinelone_client_token is defined 47 | - sentinelone_client_token | length > 0 48 | 49 | - name: Set Group/Site token 50 | ansible.builtin.command: "/opt/sentinelone/bin/sentinelctl management token set {{ sentinelone_client_token }}" 51 | args: 52 | creates: /opt/sentinelone/.INITIALIZATION_COMPLETE 53 | become: true 54 | notify: Create initialization file 55 | when: sentinelone_client_token is defined and sentinelone_client_token != '' 56 | 57 | - name: Set SentinelOne customer_id 58 | ansible.builtin.command: "/opt/sentinelone/bin/sentinelctl management customer_id set {{ sentinelone_client_customer_id }}" 59 | register: customer_id_output 60 | changed_when: "'customer id successfully set' in customer_id_output.stdout|lower" 61 | become: true 62 | when: sentinelone_client_customer_id | length > 0 63 | 64 | - name: Start agent 65 | ansible.builtin.command: /opt/sentinelone/bin/sentinelctl control start 66 | register: start_output 67 | changed_when: "'agent is running' in start_output.stdout|lower" 68 | become: true 69 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tests/inventory: -------------------------------------------------------------------------------- 1 | localhost 2 | 3 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/tests/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Run role 3 | hosts: localhost 4 | remote_user: root 5 | roles: 6 | - role: sva.sentinelone.sentinelone_client_legacy 7 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/vars/debian.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/vars/redhat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /roles/sentinelone_client_legacy/vars/suse.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svalabs/sva.sentinelone/4293c88ccbd63c236d0cf47fc42a7b7efc580c23/tests/.gitkeep --------------------------------------------------------------------------------