diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7df7f72..083b196 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ -# This CODEOWNERS file lets Jake approve reviews for all files in the repository, from either of his accounts. - -* @0x41424142 @jake-lindsay-tfs +# This CODEOWNERS file lets Jake approve reviews for all files in the repository, from either of his accounts. + +* @0x41424142 @jake-lindsay-tfs diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index c526434..9b9c4fa 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,25 +1,25 @@ -name: black-formatter -on: [push, pull_request] -jobs: - linter_name: - name: runner / black - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Check files using the black formatter - uses: rickstaa/action-black@v1 - id: action_black - with: - black_args: "." - - name: Create Pull Request - if: steps.action_black.outputs.is_formatted == 'true' - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - title: "Format Python code with psf/black push" - commit-message: ":art: Format Python code with psf/black" - body: | - There appear to be some python formatting errors in ${{ github.sha }}. This pull request - uses the [psf/black](https://github.com/psf/black) formatter to fix these issues. - base: ${{ github.head_ref }} # Creates pull request onto pull request or commit branch +name: black-formatter +on: [push, pull_request] +jobs: + linter_name: + name: runner / black + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check files using the black formatter + uses: rickstaa/action-black@v1 + id: action_black + with: + black_args: "." + - name: Create Pull Request + if: steps.action_black.outputs.is_formatted == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + title: "Format Python code with psf/black push" + commit-message: ":art: Format Python code with psf/black" + body: | + There appear to be some python formatting errors in ${{ github.sha }}. This pull request + uses the [psf/black](https://github.com/psf/black) formatter to fix these issues. + base: ${{ github.head_ref }} # Creates pull request onto pull request or commit branch branch: actions/black \ No newline at end of file diff --git a/.gitignore b/.gitignore index c09c6ee..15234c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,172 +1,172 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -#testing/profiling files... -t.py -hldTest.py -demo.html -profile.html -profile.json - -# VSCode: -.vscode/ - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +#testing/profiling files... +t.py +hldTest.py +demo.html +profile.html +profile.json + +# VSCode: +.vscode/ + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE index 6977608..b233def 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2024 Jakob Lindsay - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +MIT License + +Copyright (c) 2024 Jakob Lindsay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index de46567..3fdc7e4 100644 --- a/README.md +++ b/README.md @@ -1,642 +1,689 @@ -# qualyspy - A Python Package for Interacting With Qualys APIs -``` -·············································· -: ____ _ : -: /___ \_ _ __ _| |_ _ ___ _ __ _ _ : -: // / | | | |/ _` | | | | / __| '_ \| | | |: -:/ \_/ /| |_| | (_| | | |_| \__ | |_) | |_| |: -:\___,_\ \__,_|\__,_|_|\__, |___| .__/ \__, |: -: |___/ |_| |___/ : -·············································· - ``` - -This package attempts to make it much easier to interact with Qualys's various API endpoints, across most modules. - -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ![development status](https://img.shields.io/badge/in%20development-8A2BE2) ![black formatter status](https://github.com/0x41424142/qualyspy/actions/workflows/black.yml/badge.svg?event=push) - -## Uber Class Example -```py -from qualyspy import TokenAuth, GAVUber - -auth = TokenAuth(, , platform='qg1') -uber = GAVUber(auth) - -assets = uber.get( - "query_assets", - filter='operatingSystem:"Linux"', - lastModifiedDate="2024-06-21" - ) ->>>[AssetID(012345678), ...] -``` -## Non-Uber Class Example -```py -from qualyspy.auth import TokenAuth -from qualyspy.gav import query_assets - -auth = TokenAuth(,'password', platform='qg1') - -linux_servers_with_1cpu = query_assets( - auth, - filter='operatingSystem.category1:`Linux` and operatingSystem.category2:`Server` and processors.numberOfCpu:1', - lastModifiedDate='2024-06-21' -) ->>> linux_servers_with_1core -[AssetID(<0123>), AssetID(<4567>), ...] -``` - -## Current Supported Modules -|Module| Status | -|--|--| -| GAV (Global AssetView) |✅| -| VMDR | In Progress (```query_kb```, ```get_host_list```, ```get_hld```, ```get_ag_list```, ```add/edit/remove_ag```, ```get_ip_list```, ```add/update_ips``` implemented) | -| PM (Patch Management) | In Planning | -| WAS | Not Started | -| TC (TotalCloud) | Not Started | -|Connectors | Not Started | -|Cloud Agent | Not Started | -|CS (Container Security) | Not Started -|ADMIN (Administration) | Not Started -|Tagging| Not Started - -# Getting Started - -To install using poetry, run the following commands: -```bash -git clone https://0x41424142/qualyspy.git -cd qualyspy -poetry shell #if you want to use a venv -poetry install -``` -You can also install using pip, preferably in a virtual environment: -```bash -git clone https://0x41424142/qualyspy.git -pip install qualyspy -``` - -# Auth Classes - -```qualyspy``` supports both HTTP Basic Authentication (used mainly for VMDR-based calls) as well as JWT Authentication. - ->**Pro Tip**: Both ```BasicAuth``` and ```TokenAuth``` can be used as **context managers**! - ->**Heads Up!**: By default, auth classes assume your Qualys subscription is on the ```qg3``` platform. If this is not the case, simply pass ```platform='qg'``` where \ is 1-4 when creating the object. - -When calling an API endpoint, just pass your ```TokenAuth``` or ```BasicAuth``` object and the tool will handle the rest (or yell at you if you pass the wrong type, shown below): -```py -#Example of using the wrong auth type -from qualyspy.auth import BasicAuth -from qualyspy.gav import count_assets #GAV expects JWT auth - -with BasicAuth(,, platform='qg1') as auth: - count = count_assets(auth, filter='operatingSystem.category1:`Linux`') - ... - ->>>qualyspy.exceptions.Exceptions.AuthTypeMismatchError: Auth type mismatch. Expected token but got basic. - ``` -
- -Both ```BasicAuth``` and ```TokenAuth``` also have ```from_dict``` class methods, which allows for the creation of these objects from dictionaries: -```py -from qualyspy.auth import BasicAuth -auth = BasicAuth.from_dict({'username':, 'password':}) -``` -You can also create an object using a JSON string using ```from_json_string```: -```py -from qualyspy.auth import BasicAuth -auth = BasicAuth.from_json_string('{"username":, "password":}') -``` - -You can also export using ```to_json_string```. If ```pretty=True```, the string will be pretty formatted: -```py -from qualyspy.auth import BasicAuth -auth = BasicAuth.from_dict({'username':, 'password':}) - -#No formatting: -auth.to_json_string() ->>>'{"username": , "password": , "token": null, "auth_type": "basic", "platform": }' -#With formatting: -auth.to_json_string(pretty=True) ->>>{ - "username": , - "password": , - "token": null, - "auth_type": "basic", - "platform": -} -``` - -## Auth Class Hierarchy -```mermaid -graph -A[qualyspy.auth.base.BaseAuthentication]-->B(qualyspy.auth.basic.BasicAuth) -B --> C(qualyspy.auth.token.TokenAuth) -``` ---- - -# Global AssetView APIs -Global AssetView APIs return data on hosts within your Qualys subscription. ->**Pro Tip**: To see all available GAV QQL filters, look [here!](https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm) - -After running: -```py -from qualyspy.gav import * -``` -You can any of the 4 GAV endpoints: - -## GAV Endpoints -|API Call| Description | -|--|--| -| ```count_assets``` | Count assets based on the ```filter``` kwarg, which is written in Qualys QQL.| -```get_asset```|Get a specific host based on the ```assetId``` kwarg.| -```get_all_assets```| Pull the entire host inventory (or a few pages of it with ```page_count```), in file sizes of ```pageSize```. Does **NOT** support ```filter```.| -|```query_assets```| Scaled down version of```get_all_assets``` - pulls entire host inventory that matches the given ```filter``` kwarg. - -Or use the uber class: -```py -from qualyspy import TokenAuth, GAVUber - -#Hey look! context managers! -with TokenAuth(, , platform='qg1') as auth: - with GAVUber(auth) as uber: - full_inventory_count = uber.get("count_assets") - ... -``` - -## The GAV Host Dataclass ->**Heads Up!**: The ```Host``` class does not apply to ```count_assets()``` - -When results are received from a GAV API, each host record is stored in a ```Host``` object, with its data points as attributes. The ```Host``` class is decorated with ```@dataclass(frozen=True)``` to maintain consistency with the Qualys platform. - -Chances are, there will be a good chunk of attributes returned from Qualys that will be null. To deal with this, almost all attributes are defined as ```typing.Optional[]```, with the main exception being ```assetId```. It is also somewhat likely that I have mistyped certain attributes, as both the Qualys documentation and the data I am working with to build this package return a decent amount of null values. Should you come across something, submit a PR. - -# VMDR APIs -VMDR APIs return data on vulnerabilities in your environment as well as from the Qualys KB. It also returns data on assets, IPs/subnets, asset groups, and more. - -After running: -```py -from qualyspy.vmdr import * -``` -You can use any of the VMDR endpoints currently supported: - -## VMDR Endpoints -|API Call| Description | -|--|--| -| ```query_kb``` | Query the Qualys KnowledgeBase (KB) for vulnerabilities.| -| ```get_host_list``` | Query your VMDR host inventory based on kwargs. | -|```get_hld``` | Query your VMDR host inventory with QID detections under the ```VMDRHost.DETECTION_LIST``` attribute. - -## Host List Detection -```vmdr.get_hld()``` is the main API for extracting vulnerabilities out of the Qualys platform. It is one of the slowest APIs to return data due to Qualys taking a while to gather all the necessary data, but is arguably the most important. Pagination is controlled via the ```page_count``` parameter. By default, this is set to ```"all"```, pulling all pages. You can specify an int to limit pagination, as well as ```truncation_limit``` to specify how many hosts should be returned per page. - -This function implements threading to significantly speed up data pulls. The number of threads is controlled by the ```threads``` parameter, which defaults to 5. A ```Queue``` object is created, containing chunks of hostIDs (pulled via ```get_host_list``` with ```details=None```) that the threads pop from. The threads then call the ```hld_backend``` function with the hostIDs they popped from the queue. The user can control how many IDs are in a chunk via the ```chunk_size``` parameter, which defaults to 3000. You should create a combination of ```threads``` and ```chunk_size``` that keeps all threads busy, while respecting your Qualys concurrency limit. There is also the ```chunk_count``` parameter, which controls how many chunks a thread will pull out of the ```Queue``` before it exits. - -Some important kwargs this API accepts: -|Kwarg| Possible Values |Description| -|--|--|--| -|```show_tags```| ```False/True```|Boolean on if API output should include Qualys host tags. Accessible under ```.TAGS```. Defaults to False.| -|```host_metadata```| ```'all','ec2','azure'```|Controls if cloud host details should be returned. It is **highly recommended** to use ```all``` if specified.| -|```show_cloud_tags```| ```False/True```|Boolean on if API output should include cloud provider tags. Accessible under ```.CLOUD_TAGS```. Defaults to False.| -|```filter_superseded_qids```|```False/True```|Boolean on if API output should only include non-superseded QIDs. Defaults to False.| -|```show_qds```|```False/True```|Boolean on if API output should include the Qualys Detection Score. Accessible under ```.QDS```. Defaults to False.| -|```show_qds_factors```|```False/True```|Boolean on if API output should include the Qualys Detection Score factors, such as EPSS score, CVSS score, malware hashes, and real-time threat indicators (RTIs). Accessible under ```.QDS_FACTORS```. Defaults to False.| -|```qids```|```None/QID_numbers```|Filter API output to a specific set of QIDs. Can be a comma-separated string: ```1357,2468,8901```, a range: ```12345-54321```, or a single QID: ```12345```.| -|```ids```|```None/hostIDs```|Filter API output to a specific set of host IDs. Can be a comma-separated string: ```1357,2468,8901```, a range: ```12345-54321```, or a single host ID: ```12345```.| - ->**Heads Up!**: For a full breakdown of acceptable kwargs, see Qualys' documentation [here](https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf). -```py -from qualyspy import BasicAuth -from qualyspy.vmdr import get_hld - -auth = BasicAuth(, , platform='qg1') - -# Pull 2 pages containing 50 assets each that meet the following criteria: -# non-superseded QIDs, on-prem and EC2 assets, all tags included -hosts_with_detections = get_hld( - auth, show_tags=True, show_cloud_tags=True, - filter_superseded_qids=True, host_metadata='ec2', - page_count=2, truncation_limit=50 -) ->>>BaseList[VMDRHost(12345), ...] -``` -## VMDR Host List -The ```get_host_list()``` API returns a ```BaseList``` of VMDRHost or VMDRID dataclasses. Pagination is controlled via the ```page_count``` kwarg. By default, this is set to ```"all"```, pulling all pages. You can specify an int to limit pagination. - -Using the ```details``` kwarg, the shape of the output can be controlled: - -|Details Value|Description| -|--|--| -|```None/"None"```| Return ```list[int]``` of host IDs (or asset IDs if ```show_asset_id=1```).| -|```"Basic"```| Return ```list[dict]``` containing basic host details, such as ID, DNS, IP, OS.| -|```"Basic/AGs"```| Return a ```list[dict]``` containing basic host details, plus asset group information.| -|```"All"```| Return a ```list[dict]``` containing all host details.| -|```"All/AGs"```| Return a ```list[dict]``` containing all host details plus asset group information. - -```py -from qualyspy import BasicAuth -from qualyspy.vmdr import get_host_list - -auth = BasicAuth(, , platform='qg1') - -#Pull 4 pages of hosts, with "All/AGs" details & tags, -# where VM scan results were processed after a specific date: -yesterdays_scanned_assets = get_host_list( - auth, - details="All/AGs", - show_tags=True, - vm_processed_after="2024-06-21", - page_count=4 -) -``` - -## IP Management - -This collection of APIs allows for the management of IP addresses/ranges in VMDR, located under ```qualyspy.vmdr.ips```. The APIs are as follows: - -|API Call| Description| -|--|--| -|```get_ip_list```| Get a list of IP addresses or ranges in VMDR.| -|```add_ips```| Add IP addresses or ranges to VMDR.| -|```update_ips```| Change details of IP addresses or ranges from VMDR.| ---- -### Get IP List API - -The ```get_ip_list()``` API returns a list of all IP addresses or ranges in VMDR, matching the given kwargs. Acceptable params are: -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to search for.|❌| -|```network_id```|```str```|The network ID to search for.|❌ (usually not even enabled in a Qualys subscription)| -|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| Return IPs/ranges based on the tracking method.|❌| -|```compliance_enabled```|```bool```|Return IPs/ranges based on if compliance tracking is enabled on it.|❌| -|```certview_enabled```|```bool```|Return IPs/ranges based on if CertView tracking is enabled on it.|❌| - -```py -from qualyspy import BasicAuth -from qualyspy.vmdr.ips import get_ip_list - -auth = BasicAuth(, , platform='qg1') - -#Get all IP addresses/ranges in VMDR that have CertView tracking enabled: -certview_ips = get_ip_list(auth, certview_enabled=True) - -#Get specific IP addresses/ranges: -specific_ips = get_ip_list(auth, ips='1.2.3.4,5.6.7.8,9.10.11.12/24') - -#Slice the list of IP addresses/ranges to those that are external: -external_ips = [i for i in get_ip_list(auth) if not i.is_private] -``` ---- -### Add IPs API -The ```add_ips()``` API allows for the addition of IP addresses or ranges to VMDR. Acceptable params are: -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to add.|✅| -|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| The tracking method to use for the IP address/range.|❌| -|```enable_pc```|```bool```|Enable Policy Compliance tracking on the IP address/range.|See **Heads Up!** below.| -|```enable_vm```|```bool```|Enable Vulnerability Management tracking on the IP address/range.|See **Heads Up!** below.| -|```enable_sca```|```bool```|Enable Security Configuration Assessment tracking on the IP address/range.|See **Heads Up!** below.| -|```enable_certview```|```bool```|Enable CertView tracking on the IP address/range.|See **Heads Up!** below.| -|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```|The tracking method to use for the IP address/range. Defaults to IP.|❌| -|```owner```|```str```|The owner of the IP address/range.|❌| -|```ud1```|```str```|The user-defined field 1 (comment).|❌| -|```ud2```|```str```|The user-defined field 2 (comment).|❌| -|```ud3```|```str```|The user-defined field 3 (comment).|❌| -|```comment```|```str```|A comment to add to the IP address/range.|❌| -|```ag_title```|```str```|The title of the asset group to add the IP address/range to.|❌| - ->**Heads Up!**: At least one of the following must be enabled: ```enable_pc```, ```enable_vm```, ```enable_sca```, or ```enable_certview```, or the API will return an error. - -```py -from qualyspy import BasicAuth -from qualyspy.vmdr.ips import add_ips - -auth = BasicAuth(, , platform='qg1') - -#Add an IP address/range to VMDR with VM tracking enabled: -add_ips(auth, ips='1.2.3.4', enable_vm=True) -``` ---- -### Update IPs API -The ```update_ips()``` API allows for the modification of IP addresses or ranges in VMDR. Acceptable params are: -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to update.|✅| -|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| The tracking method to use for the IP address/range.|❌| -|```host_dns```|```str```|The DNS name of the IP address/range.|❌| -|```host_netbios```|```str```|The NetBIOS name of the IP address/range.|❌| -|```owner```|```str```|The owner of the IP address/range.|❌| -|```ud1```|```str```|The user-defined field 1 (comment).|❌| -|```ud2```|```str```|The user-defined field 2 (comment).|❌| -|```ud3```|```str```|The user-defined field 3 (comment).|❌| -|```comment```|```str```|A comment to add to the IP address/range.|❌| - -```py -from qualyspy import BasicAuth -from qualyspy.vmdr.ips import update_ips - -auth = BasicAuth(, , platform='qg1') - -#Update an IP address/range in VMDR with a new DNS name: -update_ips(auth, ips='1.2.3.4', host_dns='new_dns_name') -``` ---- -## Asset Group Management -This collection of APIs allows for the management of asset groups (AGs) in VMDR, located under ```qualyspy.vmdr.assetgroups```. The APIs are as follows: - -|API Call| Description| -|--|--| -|```get_ag_list```| Get a ```BaseList``` of ```AssetGroup``` objects.| ---- - -### Get Asset Group List API - -The ```get_ag_list()``` API returns a list of all AGs in VMDR, matching the given kwargs. Acceptable params are: -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```page_count```|```Literal['all']``` (default), ```int >= 0```| How many pages to pull. Note that ```page_count``` does not apply if ```truncation_limit``` is set to 0, or not specified.|❌| -|```ids```|```str```: '12345', '12345,6789'| Filter to specific AG IDs.|❌| -|```id_min```|```int```| Only return AGs with an ID >= ```id_min```.| ❌| -```id_max```|```int```| Only return AGs with an ID <= ```id_max```.|❌| -|```truncation_limit```| ```int```| Specify how many AGs per page. If set to 0 or not specified, returns all AGs in one pull.| ❌| -|```network_ids```|```str```: '12345', '12345,6789'| Only return AGs with specific network IDs.|❌| -|```unit_id```|```str```: 01234| Only return AGs with a specific unit ID. Only one ID is accepted.|❌| -|```user_id```|```str```| Only return AGs with a specific user assigned. Only one ID is accepted.|❌| -|```title```|```str```: "My Asset Group"| Only return AGs with a specific title. Must be an exact string match.|❌| -|```show_attributes```|```str```: 'ALL', 'ID', 'TITLE', 'ID,TITLE', ```...``` (For full list, check [Qualys documentation](https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf), under "Asset Group List" Section.| Only return specific attributes of an AG record. If not specified, basic details are returned (ID, TITLE, ```...```)|❌| - -```py -from qualyspy.auth import BasicAuth -from qualyspy.vmdr import get_ag_list - -auth = BasicAuth(, , platform='qg1') - -ag_list = get_ag_list(auth) -``` - -### Add Asset Group API -The ```add_ag()``` API allows for the addition of asset groups to VMDR. Acceptable params are: -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```title```|```str```|The title of the asset group.|✅| -|```comments```|```str```|Comments to add to the asset group.|❌| -|```division```|```str```|The division the asset group belongs to.|❌| -|```function```|```str```|The function of the asset group.|❌| -|```business_impact```|```Literal["critical", "high", "medium", "low", "none"]```|The business impact of the asset group.|❌| -|```ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to add to the asset group.|❌| -|```appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to add to the asset group.|❌| -|```default_appliance_id```|```int```|The default appliance ID for the asset group.|❌| -|```domains```|```Union[str, BaseList[str]]```|The domains to add to the asset group.|❌| -|```dns_names```|```Union[str, BaseList[str]]```|The DNS names to add to the asset group.|❌| -|```netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to add to the asset group.|❌| -|```cvss_enviro_cdp```|```Literal["high", "medium-high", "low-medium", "low", "none"]```|The CVSS environmental CDP of the asset group.|❌| -|```cvss_enviro_td```|```Literal["high", "medium", "low", "none"]```|The CVSS environmental TD of the asset group.|❌| -|```cvss_enviro_cr```|```Literal["high", "medium", "low"]```|The CVSS environmental CR of the asset group.|❌| -|```cvss_enviro_ir```|```Literal["high", "medium", "low"]```|The CVSS environmental IR of the asset group.|❌| -|```cvss_enviro_ar```|```Literal["high", "medium", "low"]```|The CVSS environmental AR of the asset group.|❌| - -```py -from qualyspy.auth import BasicAuth -from qualyspy.vmdr import add_ag - -auth = BasicAuth(, , platform='qg1') - -#Add an asset group to VMDR with a specific title: -add_ag(auth, title='My New Asset Group') ->>>Asset Group Added Successfully. -``` - -### Edit Asset Group API -The ```edit_ag()``` API allows for the modification of asset groups in VMDR. Acceptable params are: - -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```id```|```Union[AssetGroup, BaseList[AssetGroup, int, str], str]```|The ID of the asset group to edit.|✅| -|```set_comments```|```str```|The comments to set for the asset group.|❌| -|```set_division```|```str```|The division to set for the asset group.|❌| -|```set_function```|```str```|The function to set for the asset group.|❌| -|```set_location```|```str```|The location to set for the asset group.|❌| -|```set_business_impact```|```Literal["critical", "high", "medium", "low", "none"]```|The business impact to set for the asset group.|❌| -|```add_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to add to the asset group.|❌| -|```remove_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to remove from the asset group.|❌| -|```set_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to set for the asset group.|❌| -|```add_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to add to the asset group.|❌| -|```remove_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to remove from the asset group.|❌| -|```set_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to set for the asset group.|❌| -|```set_default_appliance_id```|```int```|The default appliance ID to set for the asset group.|❌| -|```add_domains```|```Union[str, BaseList[str]]```|The domains to add to the asset group.|❌| -|```remove_domains```|```Union[str, BaseList[str]]```|The domains to remove from the asset group.|❌| -|```set_domains```|```Union[str, BaseList[str]]```|The domains to set for the asset group.|❌| -|```add_dns_names```|```Union[str, BaseList[str]]```|The DNS names to add to the asset group.|❌| -|```remove_dns_names```|```Union[str, BaseList[str]]```|The DNS names to remove from the asset group.|❌| -|```set_dns_names```|```Union[str, BaseList[str]]```|The DNS names to set for the asset group.|❌| -|```add_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to add to the asset group.|❌| -|```remove_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to remove from the asset group.|❌| -|```set_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to set for the asset group.|❌| -|```set_title```|```str```|The title to set for the asset group.|❌| -|```set_cvss_enviro_cdp```|```Literal["high", "medium-high", "low-medium", "low", "none"]```|The CVSS environmental CDP to set for the asset group.|❌| -|```set_cvss_enviro_td```|```Literal["high", "medium", "low", "none"]```|The CVSS environmental TD to set for the asset group.|❌| -|```set_cvss_enviro_cr```|```Literal["high", "medium", "low"]```|The CVSS environmental CR to set for the asset group.|❌| -|```set_cvss_enviro_ir```|```Literal["high", "medium", "low"]```|The CVSS environmental IR to set for the asset group.|❌| -|```set_cvss_enviro_ar```|```Literal["high", "medium", "low"]```|The CVSS environmental AR to set for the asset group.|❌| - -```py -from qualyspy.auth import BasicAuth -from qualyspy.vmdr import edit_ag - -auth = BasicAuth(, , platform='qg1') - -#Edit an asset group in VMDR with a new title: -edit_ag(auth, id=12345, set_title='My New Asset Group Title') ->>>Asset Group Updated Successfully. -``` ---- - -### Delete Asset Group API -The ```delete_ag()``` API allows for the deletion of asset groups in VMDR. Acceptable params are: - -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```id```|```Union[AssetGroup, BaseList[AssetGroup, int, str], str]```|The ID of the asset group to delete.|✅| - -```py -from qualyspy.auth import BasicAuth -from qualyspy.vmdr import delete_ag - -auth = BasicAuth(, , platform='qg1') - -#Delete an asset group in VMDR: -delete_ag(auth, id=12345) ->>>Asset Group Deleted Successfully. -``` ---- -## VM Scan Management -This collection of APIs allows for the management of VM scans in VMDR, located under ```qualyspy.vmdr.vmscans```. The APIs are as follows: - -|API Call| Description| -|--|--| -|```get_scan_list```| Get a ```BaseList``` of ```VMScan``` objects.| - -### VMScan Dataclass -The ```VMScan``` dataclass is used to store the various fields that the VMDR VM Scan APIs return. Attributes are as follows: -|Attribute|Type|Description| -|--|--|--| -|```REF```|```str```|Reference string for the scan. Formatted as module/ID.| -|```TYPE```|```Literal["On-Demand","API","Scheduled]```|How the scan is ran.| -|```TITLE```|```str```|The scan name.| -|```USER_LOGIN```|```str```|The Qualys account that created/owns the scan.| -|```LAUNCH_DATETIME```|```datetime.datetime```|The date and time the scan was launched.| -|```DURATION```|```datetime.timedelta```|The duration of the scan.| -|```PROCESSING_PRIORTIY```|```str```|The processing priority of the scan. Includes an int followed by a description of the priority level, such as: ```0 - No Priority```.| -|```PROCESSED```|```bool```|If the scan results have been processed.| -|```STATUS```|```dict```|Status metadata points of the scan. Includes ```state```, which is saved into the ```STATE``` attribute.| -|```STATE```|```str```|The state of the scan.| -|```TARGET```|```Union[str, BaseList[str], BaseList[ipaddress.IPv4Address, ipaddress.IPv4Network]]```|The target IPs for the scan.| -|```OPTION_PROFILE```|```dict```|The option profile metadata for the scan.| -|```ASSET_GROUP_TITLE_LIST```|```BaseList[str]```|The asset group titles covered by the scan.| ---- -### Get Scan List API - -The ```get_scan_list()``` API returns a list of all VM scans in VMDR, matching the given kwargs. Acceptable params are: - -|Parameter| Possible Values |Description|Required| -|--|--|--|--| -|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| -|```scan_ref```|```str```|The reference string of the scan to search for. Formatted like: ```scan/123455677```|❌| -|```state```|```Literal["Running", "Paused", "Cancelled", "Finished", "Error", "Queued", "Loading"]```|Filter by the state of the scan.|❌| -|```processed```|```bool```|Filter by if the scan results have been processed.|❌| -|```type```|```Literal["On-Demand","API","Scheduled]```|Filter by how the scan is set up.|❌| -|```user_login```|```str```|Filter by the Qualys account that created/owns the scan.|❌| -|```launched_after_datetime```|```str```|Filter by scans launched after a specific datetime. Formatted as: ```2007-07-01``` or ```2007-01-25T23:12:00Z```|❌| -|```launched_before_datetime```|```str```|Filter by scans launched before a specific datetime. Formatted as: ```2007-07-01``` or ```2007-01-25T23:12:00Z```|❌| -|```scan_type```|```Literal["certview", "ec2certview"]```|Only return certview scans, or EC2 certview scans.|❌| -|```client_id```|```Union[str,int]```|Filter by the client ID of the scan. This must be enabled in the Qualys subscription.|❌| -|```client_name```|```str```|Filter by the client name of the scan. This must be enabled in the Qualys subscription.|❌| -|```show_ags```|```bool```|Include asset group titles in the scan list.|❌| -|```show_op```|```bool```|Include option profile metadata in the scan list.|❌| -|```show_status```|```bool```|Include status metadata in the scan list. Defaults to ```True```.|❌| -|```show_last```|```bool```|Only show the last run of each scan. Defaults to ```False```.|❌| -|```ignore_target```|```bool```|Ignore the target IPs of the scan. Defaults to ```False```.|❌| - -```py -from qualyspy import BasicAuth -from qualyspy.vmdr import get_scan_list - -auth = BasicAuth(, , platform='qg1') - -#Get all VM scans in VMDR, with all details, that have a type of Scheduled: -scheduled_scans = get_scan_list(auth, type='Scheduled', show_ags=True, show_op=True) ->>>BaseList[VMScan(REF='scan/123456789', TYPE='Scheduled', TITLE='My Scheduled Scan', ...), ...] -``` ---- -## Querying the KB -The Qualys KnowledgeBase (KB) is a collection of vulnerabilities that Qualys has identified. You can query the KB using the ```query_kb()``` function: ->**Heads Up!**: When calling ```query_kb()```, the function returns a regular list of ```KBEntry``` objects. -```py -from qualyspy import BasicAuth, vmdr - -with BasicAuth(, , platform='qg1') as auth: - #Full KB pull: - kb_query = vmdr.query_kb(auth) - - #or use kwargs to filter, - # for example QIDs published for a specific week: - kb_query = vmdr.query_kb(auth, published_after='2024-06-21', published_before='2024-06-28') - - #Want to search the list of - # KBEntries based on some criteria? - in_scope_qids = [entry for entry in kb_query if entry.QID in range(1000, 2000)] - len(in_scope_qids) ->>>400 -``` - -## Special Dataclasses for VMDR - -There are quite a few special dataclasses that are used in the VMDR module, as well as a ```BaseList``` class that is used to store these dataclasses and add some easier string functionality. - -For example, for KB entries, there is the ```KBEntry``` class which holds the various fields that the Qualys KB API returns. Inside a ```KBEntry``` object there are custom classes for things like ```ThreatIntel``` and ```Software```. Other examples include the ```VMDRHost``` class, which holds the various fields that the VMDR Host List API returns, and the ```Detection``` class, which holds the various fields that the VMDR Host List Detection API returns under a ```VMDRHost```. -```py -... #Prior KB pull - -#Get the ThreatIntel attribute of the a KBEntry object, which is a custom dataclass: -kb_entry.THREAT_INTELLIGENCE ->>>BaseList([ThreatIntel(ID=4, TEXT='High_Lateral_Movement')]) - -#Or perhaps you want all the CVEs in a CVEList as a comma-separated string: -str(kb_entry.CVEList) ->>>'CVE-2024-1234, CVE-2024-5678, ...' -``` - -### KB Dataclasses -|Class| Attributes | -|--|--| -| ```VendorReference``` | ID, URL| -| ```ThreatIntel```| ID, TEXT| -| ```Software``` | PRODUCT, VENDOR | -|```CVEID```| ID, URL | -|```Compliance``` | _TYPE, SECTION, DESCRIPTION | -| ```Bugtraq``` | ID, URL | ------ - -# The CALL_SCHEMA Dictionary ->**TL;DR**: The ```CALL_SCHEMA``` is ***VERY*** important! - -CALL_SCHEMA is a backend ```frozendict``` dictionary that the package automatically uses to correctly set up the underlying ```qualyspy.base.call_api()``` function. ```call_api``` in turn then sets up the appropriate ```requests.Request()``` call using an endpoint's schema. - -The schema stores information such as what HTTP methods the endpoint accepts, the authentication type the endpoint expects, its path, acceptable kwargs when calling an API (and whether a kwarg should be sent as a URL parameter, in a POST form, or using the requests' library's ```json=``` feature), what Qualys URL structure to use (gateway vs. qualysapi), and more. - -The schema also allows all API calls to raise an exception if the user passes in a kwarg that is not valid for an endpoint. - -There are also some values that do not influence program behavior, but are "good-to-knows" for users. See below. - -## Querying the CALL_SCHEMA -If you want to take a look at what an endpoint (or what an entire module's collection of endpoints!) expects, you can do so programmatically: ->**Pro Tip!**: use ```schema_query(...pretty=True)``` to return a beautified string of the query results. - -```py -from qualyspy import schema_query - -#Get one specific endpoint's schema: -print(schema_query(module='gav', endpoint='query_assets')) ->>>{'endpoint': '/am/v1/assets/host/filter/list', 'method': ['POST'], 'valid_params': ['filter', 'excludeFields', 'includeFields', 'lastModifiedDate', 'lastSeenAssetId', 'pageSize'], 'valid_POST_data': [], 'use_requests_json_data': False, 'return_type': 'json', 'pagination': True, 'auth_type': 'token'} - -#Get an entire module's worth of endpoint schemas, -# which includes the module-level url_type: -print(schema_query(module='gav', pretty=True)) ->>>{ - "url_type": "gateway", #Used by call_api() to determine which URL format to use. - "count_assets": { - "endpoint": "/am/v1/assets/host/count", - "method": [ - "POST" - ], - "valid_params": [ - "filter", - "lastSeenAssetId", - "lastModifiedDate" - ], - "valid_POST_data": [], - "use_requests_json_data": false, - "return_type": "json", - "pagination": false, - "auth_type": "token" - }, - "get_all_assets": { - ... - } -} -``` -# TODO: - -- Continue adding VMDR APIs. - -- Start work on PM module. - -- Write testing files. - -- Break up README.md: Move module-specific sections to their respective folders, cleaning up the main README.md. +# qualyspy - A Python Package for Interacting With Qualys APIs +``` +·············································· +: ____ _ : +: /___ \_ _ __ _| |_ _ ___ _ __ _ _ : +: // / | | | |/ _` | | | | / __| '_ \| | | |: +:/ \_/ /| |_| | (_| | | |_| \__ | |_) | |_| |: +:\___,_\ \__,_|\__,_|_|\__, |___| .__/ \__, |: +: |___/ |_| |___/ : +·············································· + ``` + +This package attempts to make it much easier to interact with Qualys's various API endpoints, across most modules. + +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ![development status](https://img.shields.io/badge/in%20development-8A2BE2) ![black formatter status](https://github.com/0x41424142/qualyspy/actions/workflows/black.yml/badge.svg?event=push) + +## Uber Class Example +```py +from qualyspy import TokenAuth, GAVUber + +auth = TokenAuth(, , platform='qg1') +uber = GAVUber(auth) + +assets = uber.get( + "query_assets", + filter='operatingSystem:"Linux"', + lastModifiedDate="2024-06-21" + ) +>>>[AssetID(012345678), ...] +``` +## Non-Uber Class Example +```py +from qualyspy.auth import TokenAuth +from qualyspy.gav import query_assets + +auth = TokenAuth(,'password', platform='qg1') + +linux_servers_with_1cpu = query_assets( + auth, + filter='operatingSystem.category1:`Linux` and operatingSystem.category2:`Server` and processors.numberOfCpu:1', + lastModifiedDate='2024-06-21' +) +>>> linux_servers_with_1core +[AssetID(<0123>), AssetID(<4567>), ...] +``` + +## Current Supported Modules +|Module| Status | +|--|--| +| GAV (Global AssetView) |✅| +| VMDR | In Progress (```query_kb```, ```get_host_list```, ```get_hld```, ```get_ag_list```, ```add/edit/remove_ag```, ```get_ip_list```, ```add/update_ips``` implemented) | +| PM (Patch Management) | In Planning | +| WAS | Not Started | +| TC (TotalCloud) | Not Started | +|Connectors | Not Started | +|Cloud Agent | Not Started | +|CS (Container Security) | Not Started +|ADMIN (Administration) | Not Started +|Tagging| Not Started + +# Getting Started + +To install using poetry, run the following commands: +```bash +git clone https://0x41424142/qualyspy.git +cd qualyspy +poetry shell #if you want to use a venv +poetry install +``` +You can also install using pip, preferably in a virtual environment: +```bash +git clone https://0x41424142/qualyspy.git +pip install qualyspy +``` + +# Auth Classes + +```qualyspy``` supports both HTTP Basic Authentication (used mainly for VMDR-based calls) as well as JWT Authentication. + +>**Pro Tip**: Both ```BasicAuth``` and ```TokenAuth``` can be used as **context managers**! + +>**Heads Up!**: By default, auth classes assume your Qualys subscription is on the ```qg3``` platform. If this is not the case, simply pass ```platform='qg'``` where \ is 1-4 when creating the object. + +When calling an API endpoint, just pass your ```TokenAuth``` or ```BasicAuth``` object and the tool will handle the rest (or yell at you if you pass the wrong type, shown below): +```py +#Example of using the wrong auth type +from qualyspy.auth import BasicAuth +from qualyspy.gav import count_assets #GAV expects JWT auth + +with BasicAuth(,, platform='qg1') as auth: + count = count_assets(auth, filter='operatingSystem.category1:`Linux`') + ... + +>>>qualyspy.exceptions.Exceptions.AuthTypeMismatchError: Auth type mismatch. Expected token but got basic. + ``` +
+ +Both ```BasicAuth``` and ```TokenAuth``` also have ```from_dict``` class methods, which allows for the creation of these objects from dictionaries: +```py +from qualyspy.auth import BasicAuth +auth = BasicAuth.from_dict({'username':, 'password':}) +``` +You can also create an object using a JSON string using ```from_json_string```: +```py +from qualyspy.auth import BasicAuth +auth = BasicAuth.from_json_string('{"username":, "password":}') +``` + +You can also export using ```to_json_string```. If ```pretty=True```, the string will be pretty formatted: +```py +from qualyspy.auth import BasicAuth +auth = BasicAuth.from_dict({'username':, 'password':}) + +#No formatting: +auth.to_json_string() +>>>'{"username": , "password": , "token": null, "auth_type": "basic", "platform": }' +#With formatting: +auth.to_json_string(pretty=True) +>>>{ + "username": , + "password": , + "token": null, + "auth_type": "basic", + "platform": +} +``` + +## Auth Class Hierarchy +```mermaid +graph +A[qualyspy.auth.base.BaseAuthentication]-->B(qualyspy.auth.basic.BasicAuth) +B --> C(qualyspy.auth.token.TokenAuth) +``` +--- + +# Global AssetView APIs +Global AssetView APIs return data on hosts within your Qualys subscription. +>**Pro Tip**: To see all available GAV QQL filters, look [here!](https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm) + +After running: +```py +from qualyspy.gav import * +``` +You can any of the 4 GAV endpoints: + +## GAV Endpoints +|API Call| Description | +|--|--| +| ```count_assets``` | Count assets based on the ```filter``` kwarg, which is written in Qualys QQL.| +```get_asset```|Get a specific host based on the ```assetId``` kwarg.| +```get_all_assets```| Pull the entire host inventory (or a few pages of it with ```page_count```), in file sizes of ```pageSize```. Does **NOT** support ```filter```.| +|```query_assets```| Scaled down version of```get_all_assets``` - pulls entire host inventory that matches the given ```filter``` kwarg. + +Or use the uber class: +```py +from qualyspy import TokenAuth, GAVUber + +#Hey look! context managers! +with TokenAuth(, , platform='qg1') as auth: + with GAVUber(auth) as uber: + full_inventory_count = uber.get("count_assets") + ... +``` + +## The GAV Host Dataclass +>**Heads Up!**: The ```Host``` class does not apply to ```count_assets()``` + +When results are received from a GAV API, each host record is stored in a ```Host``` object, with its data points as attributes. The ```Host``` class is decorated with ```@dataclass(frozen=True)``` to maintain consistency with the Qualys platform. + +Chances are, there will be a good chunk of attributes returned from Qualys that will be null. To deal with this, almost all attributes are defined as ```typing.Optional[]```, with the main exception being ```assetId```. It is also somewhat likely that I have mistyped certain attributes, as both the Qualys documentation and the data I am working with to build this package return a decent amount of null values. Should you come across something, submit a PR. + +# VMDR APIs +VMDR APIs return data on vulnerabilities in your environment as well as from the Qualys KB. It also returns data on assets, IPs/subnets, asset groups, and more. + +After running: +```py +from qualyspy.vmdr import * +``` +You can use any of the VMDR endpoints currently supported: + +## VMDR Endpoints +|API Call| Description | +|--|--| +| ```query_kb``` | Query the Qualys KnowledgeBase (KB) for vulnerabilities.| +| ```get_host_list``` | Query your VMDR host inventory based on kwargs. | +|```get_hld``` | Query your VMDR host inventory with QID detections under the ```VMDRHost.DETECTION_LIST``` attribute. + +## Host List Detection +```vmdr.get_hld()``` is the main API for extracting vulnerabilities out of the Qualys platform. It is one of the slowest APIs to return data due to Qualys taking a while to gather all the necessary data, but is arguably the most important. Pagination is controlled via the ```page_count``` parameter. By default, this is set to ```"all"```, pulling all pages. You can specify an int to limit pagination, as well as ```truncation_limit``` to specify how many hosts should be returned per page. + +This function implements threading to significantly speed up data pulls. The number of threads is controlled by the ```threads``` parameter, which defaults to 5. A ```Queue``` object is created, containing chunks of hostIDs (pulled via ```get_host_list``` with ```details=None```) that the threads pop from. The threads then call the ```hld_backend``` function with the hostIDs they popped from the queue. The user can control how many IDs are in a chunk via the ```chunk_size``` parameter, which defaults to 3000. You should create a combination of ```threads``` and ```chunk_size``` that keeps all threads busy, while respecting your Qualys concurrency limit. There is also the ```chunk_count``` parameter, which controls how many chunks a thread will pull out of the ```Queue``` before it exits. + +Some important kwargs this API accepts: +|Kwarg| Possible Values |Description| +|--|--|--| +|```show_tags```| ```False/True```|Boolean on if API output should include Qualys host tags. Accessible under ```.TAGS```. Defaults to False.| +|```host_metadata```| ```'all','ec2','azure'```|Controls if cloud host details should be returned. It is **highly recommended** to use ```all``` if specified.| +|```show_cloud_tags```| ```False/True```|Boolean on if API output should include cloud provider tags. Accessible under ```.CLOUD_TAGS```. Defaults to False.| +|```filter_superseded_qids```|```False/True```|Boolean on if API output should only include non-superseded QIDs. Defaults to False.| +|```show_qds```|```False/True```|Boolean on if API output should include the Qualys Detection Score. Accessible under ```.QDS```. Defaults to False.| +|```show_qds_factors```|```False/True```|Boolean on if API output should include the Qualys Detection Score factors, such as EPSS score, CVSS score, malware hashes, and real-time threat indicators (RTIs). Accessible under ```.QDS_FACTORS```. Defaults to False.| +|```qids```|```None/QID_numbers```|Filter API output to a specific set of QIDs. Can be a comma-separated string: ```1357,2468,8901```, a range: ```12345-54321```, or a single QID: ```12345```.| +|```ids```|```None/hostIDs```|Filter API output to a specific set of host IDs. Can be a comma-separated string: ```1357,2468,8901```, a range: ```12345-54321```, or a single host ID: ```12345```.| + +>**Heads Up!**: For a full breakdown of acceptable kwargs, see Qualys' documentation [here](https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf). +```py +from qualyspy import BasicAuth +from qualyspy.vmdr import get_hld + +auth = BasicAuth(, , platform='qg1') + +# Pull 2 pages containing 50 assets each that meet the following criteria: +# non-superseded QIDs, on-prem and EC2 assets, all tags included +hosts_with_detections = get_hld( + auth, show_tags=True, show_cloud_tags=True, + filter_superseded_qids=True, host_metadata='ec2', + page_count=2, truncation_limit=50 +) +>>>BaseList[VMDRHost(12345), ...] +``` +## VMDR Host List +The ```get_host_list()``` API returns a ```BaseList``` of VMDRHost or VMDRID dataclasses. Pagination is controlled via the ```page_count``` kwarg. By default, this is set to ```"all"```, pulling all pages. You can specify an int to limit pagination. + +Using the ```details``` kwarg, the shape of the output can be controlled: + +|Details Value|Description| +|--|--| +|```None/"None"```| Return ```list[int]``` of host IDs (or asset IDs if ```show_asset_id=1```).| +|```"Basic"```| Return ```list[dict]``` containing basic host details, such as ID, DNS, IP, OS.| +|```"Basic/AGs"```| Return a ```list[dict]``` containing basic host details, plus asset group information.| +|```"All"```| Return a ```list[dict]``` containing all host details.| +|```"All/AGs"```| Return a ```list[dict]``` containing all host details plus asset group information. + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr import get_host_list + +auth = BasicAuth(, , platform='qg1') + +#Pull 4 pages of hosts, with "All/AGs" details & tags, +# where VM scan results were processed after a specific date: +yesterdays_scanned_assets = get_host_list( + auth, + details="All/AGs", + show_tags=True, + vm_processed_after="2024-06-21", + page_count=4 +) +``` + +## IP Management + +This collection of APIs allows for the management of IP addresses/ranges in VMDR, located under ```qualyspy.vmdr.ips```. The APIs are as follows: + +|API Call| Description| +|--|--| +|```get_ip_list```| Get a list of IP addresses or ranges in VMDR.| +|```add_ips```| Add IP addresses or ranges to VMDR.| +|```update_ips```| Change details of IP addresses or ranges from VMDR.| +--- +### Get IP List API + +The ```get_ip_list()``` API returns a list of all IP addresses or ranges in VMDR, matching the given kwargs. Acceptable params are: +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to search for.|❌| +|```network_id```|```str```|The network ID to search for.|❌ (usually not even enabled in a Qualys subscription)| +|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| Return IPs/ranges based on the tracking method.|❌| +|```compliance_enabled```|```bool```|Return IPs/ranges based on if compliance tracking is enabled on it.|❌| +|```certview_enabled```|```bool```|Return IPs/ranges based on if CertView tracking is enabled on it.|❌| + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr.ips import get_ip_list + +auth = BasicAuth(, , platform='qg1') + +#Get all IP addresses/ranges in VMDR that have CertView tracking enabled: +certview_ips = get_ip_list(auth, certview_enabled=True) + +#Get specific IP addresses/ranges: +specific_ips = get_ip_list(auth, ips='1.2.3.4,5.6.7.8,9.10.11.12/24') + +#Slice the list of IP addresses/ranges to those that are external: +external_ips = [i for i in get_ip_list(auth) if not i.is_private] +``` +--- +### Add IPs API +The ```add_ips()``` API allows for the addition of IP addresses or ranges to VMDR. Acceptable params are: +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to add.|✅| +|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| The tracking method to use for the IP address/range.|❌| +|```enable_pc```|```bool```|Enable Policy Compliance tracking on the IP address/range.|See **Heads Up!** below.| +|```enable_vm```|```bool```|Enable Vulnerability Management tracking on the IP address/range.|See **Heads Up!** below.| +|```enable_sca```|```bool```|Enable Security Configuration Assessment tracking on the IP address/range.|See **Heads Up!** below.| +|```enable_certview```|```bool```|Enable CertView tracking on the IP address/range.|See **Heads Up!** below.| +|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```|The tracking method to use for the IP address/range. Defaults to IP.|❌| +|```owner```|```str```|The owner of the IP address/range.|❌| +|```ud1```|```str```|The user-defined field 1 (comment).|❌| +|```ud2```|```str```|The user-defined field 2 (comment).|❌| +|```ud3```|```str```|The user-defined field 3 (comment).|❌| +|```comment```|```str```|A comment to add to the IP address/range.|❌| +|```ag_title```|```str```|The title of the asset group to add the IP address/range to.|❌| + +>**Heads Up!**: At least one of the following must be enabled: ```enable_pc```, ```enable_vm```, ```enable_sca```, or ```enable_certview```, or the API will return an error. + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr.ips import add_ips + +auth = BasicAuth(, , platform='qg1') + +#Add an IP address/range to VMDR with VM tracking enabled: +add_ips(auth, ips='1.2.3.4', enable_vm=True) +``` +--- +### Update IPs API +The ```update_ips()``` API allows for the modification of IP addresses or ranges in VMDR. Acceptable params are: +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```ips```|```str()``` or ```BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]```|The IP address or range to update.|✅| +|```tracking_method```|```Literal['IP', 'DNS', 'NETBIOS']```| The tracking method to use for the IP address/range.|❌| +|```host_dns```|```str```|The DNS name of the IP address/range.|❌| +|```host_netbios```|```str```|The NetBIOS name of the IP address/range.|❌| +|```owner```|```str```|The owner of the IP address/range.|❌| +|```ud1```|```str```|The user-defined field 1 (comment).|❌| +|```ud2```|```str```|The user-defined field 2 (comment).|❌| +|```ud3```|```str```|The user-defined field 3 (comment).|❌| +|```comment```|```str```|A comment to add to the IP address/range.|❌| + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr.ips import update_ips + +auth = BasicAuth(, , platform='qg1') + +#Update an IP address/range in VMDR with a new DNS name: +update_ips(auth, ips='1.2.3.4', host_dns='new_dns_name') +``` +--- +## Asset Group Management +This collection of APIs allows for the management of asset groups (AGs) in VMDR, located under ```qualyspy.vmdr.assetgroups```. The APIs are as follows: + +|API Call| Description| +|--|--| +|```get_ag_list```| Get a ```BaseList``` of ```AssetGroup``` objects.| +--- + +### Get Asset Group List API + +The ```get_ag_list()``` API returns a list of all AGs in VMDR, matching the given kwargs. Acceptable params are: +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```page_count```|```Literal['all']``` (default), ```int >= 0```| How many pages to pull. Note that ```page_count``` does not apply if ```truncation_limit``` is set to 0, or not specified.|❌| +|```ids```|```str```: '12345', '12345,6789'| Filter to specific AG IDs.|❌| +|```id_min```|```int```| Only return AGs with an ID >= ```id_min```.| ❌| +```id_max```|```int```| Only return AGs with an ID <= ```id_max```.|❌| +|```truncation_limit```| ```int```| Specify how many AGs per page. If set to 0 or not specified, returns all AGs in one pull.| ❌| +|```network_ids```|```str```: '12345', '12345,6789'| Only return AGs with specific network IDs.|❌| +|```unit_id```|```str```: 01234| Only return AGs with a specific unit ID. Only one ID is accepted.|❌| +|```user_id```|```str```| Only return AGs with a specific user assigned. Only one ID is accepted.|❌| +|```title```|```str```: "My Asset Group"| Only return AGs with a specific title. Must be an exact string match.|❌| +|```show_attributes```|```str```: 'ALL', 'ID', 'TITLE', 'ID,TITLE', ```...``` (For full list, check [Qualys documentation](https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf), under "Asset Group List" Section.| Only return specific attributes of an AG record. If not specified, basic details are returned (ID, TITLE, ```...```)|❌| + +```py +from qualyspy.auth import BasicAuth +from qualyspy.vmdr import get_ag_list + +auth = BasicAuth(, , platform='qg1') + +ag_list = get_ag_list(auth) +``` + +### Add Asset Group API +The ```add_ag()``` API allows for the addition of asset groups to VMDR. Acceptable params are: +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```title```|```str```|The title of the asset group.|✅| +|```comments```|```str```|Comments to add to the asset group.|❌| +|```division```|```str```|The division the asset group belongs to.|❌| +|```function```|```str```|The function of the asset group.|❌| +|```business_impact```|```Literal["critical", "high", "medium", "low", "none"]```|The business impact of the asset group.|❌| +|```ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to add to the asset group.|❌| +|```appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to add to the asset group.|❌| +|```default_appliance_id```|```int```|The default appliance ID for the asset group.|❌| +|```domains```|```Union[str, BaseList[str]]```|The domains to add to the asset group.|❌| +|```dns_names```|```Union[str, BaseList[str]]```|The DNS names to add to the asset group.|❌| +|```netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to add to the asset group.|❌| +|```cvss_enviro_cdp```|```Literal["high", "medium-high", "low-medium", "low", "none"]```|The CVSS environmental CDP of the asset group.|❌| +|```cvss_enviro_td```|```Literal["high", "medium", "low", "none"]```|The CVSS environmental TD of the asset group.|❌| +|```cvss_enviro_cr```|```Literal["high", "medium", "low"]```|The CVSS environmental CR of the asset group.|❌| +|```cvss_enviro_ir```|```Literal["high", "medium", "low"]```|The CVSS environmental IR of the asset group.|❌| +|```cvss_enviro_ar```|```Literal["high", "medium", "low"]```|The CVSS environmental AR of the asset group.|❌| + +```py +from qualyspy.auth import BasicAuth +from qualyspy.vmdr import add_ag + +auth = BasicAuth(, , platform='qg1') + +#Add an asset group to VMDR with a specific title: +add_ag(auth, title='My New Asset Group') +>>>Asset Group Added Successfully. +``` + +### Edit Asset Group API +The ```edit_ag()``` API allows for the modification of asset groups in VMDR. Acceptable params are: + +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```id```|```Union[AssetGroup, BaseList[AssetGroup, int, str], str]```|The ID of the asset group to edit.|✅| +|```set_comments```|```str```|The comments to set for the asset group.|❌| +|```set_division```|```str```|The division to set for the asset group.|❌| +|```set_function```|```str```|The function to set for the asset group.|❌| +|```set_location```|```str```|The location to set for the asset group.|❌| +|```set_business_impact```|```Literal["critical", "high", "medium", "low", "none"]```|The business impact to set for the asset group.|❌| +|```add_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to add to the asset group.|❌| +|```remove_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to remove from the asset group.|❌| +|```set_ips```|```Union[str, BaseList[str, IPV4Address, IPV4Network, IPV6Address, IPV6Network]]```|The IP addresses or ranges to set for the asset group.|❌| +|```add_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to add to the asset group.|❌| +|```remove_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to remove from the asset group.|❌| +|```set_appliance_ids```|```Union[str, BaseList[int]]```|The appliance IDs to set for the asset group.|❌| +|```set_default_appliance_id```|```int```|The default appliance ID to set for the asset group.|❌| +|```add_domains```|```Union[str, BaseList[str]]```|The domains to add to the asset group.|❌| +|```remove_domains```|```Union[str, BaseList[str]]```|The domains to remove from the asset group.|❌| +|```set_domains```|```Union[str, BaseList[str]]```|The domains to set for the asset group.|❌| +|```add_dns_names```|```Union[str, BaseList[str]]```|The DNS names to add to the asset group.|❌| +|```remove_dns_names```|```Union[str, BaseList[str]]```|The DNS names to remove from the asset group.|❌| +|```set_dns_names```|```Union[str, BaseList[str]]```|The DNS names to set for the asset group.|❌| +|```add_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to add to the asset group.|❌| +|```remove_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to remove from the asset group.|❌| +|```set_netbios_names```|```Union[str, BaseList[str]]```|The NetBIOS names to set for the asset group.|❌| +|```set_title```|```str```|The title to set for the asset group.|❌| +|```set_cvss_enviro_cdp```|```Literal["high", "medium-high", "low-medium", "low", "none"]```|The CVSS environmental CDP to set for the asset group.|❌| +|```set_cvss_enviro_td```|```Literal["high", "medium", "low", "none"]```|The CVSS environmental TD to set for the asset group.|❌| +|```set_cvss_enviro_cr```|```Literal["high", "medium", "low"]```|The CVSS environmental CR to set for the asset group.|❌| +|```set_cvss_enviro_ir```|```Literal["high", "medium", "low"]```|The CVSS environmental IR to set for the asset group.|❌| +|```set_cvss_enviro_ar```|```Literal["high", "medium", "low"]```|The CVSS environmental AR to set for the asset group.|❌| + +```py +from qualyspy.auth import BasicAuth +from qualyspy.vmdr import edit_ag + +auth = BasicAuth(, , platform='qg1') + +#Edit an asset group in VMDR with a new title: +edit_ag(auth, id=12345, set_title='My New Asset Group Title') +>>>Asset Group Updated Successfully. +``` +--- + +### Delete Asset Group API +The ```delete_ag()``` API allows for the deletion of asset groups in VMDR. Acceptable params are: + +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```id```|```Union[AssetGroup, BaseList[AssetGroup, int, str], str]```|The ID of the asset group to delete.|✅| + +```py +from qualyspy.auth import BasicAuth +from qualyspy.vmdr import delete_ag + +auth = BasicAuth(, , platform='qg1') + +#Delete an asset group in VMDR: +delete_ag(auth, id=12345) +>>>Asset Group Deleted Successfully. +``` +--- +## VM Scan Management +This collection of APIs allows for the management of VM scans in VMDR, located under ```qualyspy.vmdr.vmscans```. The APIs are as follows: + +|API Call| Description| +|--|--| +|```get_scan_list```| Get a ```BaseList``` of ```VMScan``` objects.| + +### VMScan Dataclass +The ```VMScan``` dataclass is used to store the various fields that the VMDR VM Scan APIs return. Attributes are as follows: +|Attribute|Type|Description| +|--|--|--| +|```REF```|```str```|Reference string for the scan. Formatted as module/ID.| +|```TYPE```|```Literal["On-Demand","API","Scheduled]```|How the scan is ran.| +|```TITLE```|```str```|The scan name.| +|```USER_LOGIN```|```str```|The Qualys account that created/owns the scan.| +|```LAUNCH_DATETIME```|```datetime.datetime```|The date and time the scan was launched.| +|```DURATION```|```datetime.timedelta```|The duration of the scan.| +|```PROCESSING_PRIORTIY```|```str```|The processing priority of the scan. Includes an int followed by a description of the priority level, such as: ```0 - No Priority```.| +|```PROCESSED```|```bool```|If the scan results have been processed.| +|```STATUS```|```dict```|Status metadata points of the scan. Includes ```state```, which is saved into the ```STATE``` attribute.| +|```STATE```|```str```|The state of the scan.| +|```TARGET```|```Union[str, BaseList[str], BaseList[ipaddress.IPv4Address, ipaddress.IPv4Network]]```|The target IPs for the scan.| +|```OPTION_PROFILE```|```dict```|The option profile metadata for the scan.| +|```ASSET_GROUP_TITLE_LIST```|```BaseList[str]```|The asset group titles covered by the scan.| +--- +### Get Scan List API + +The ```get_scan_list()``` API returns a list of all VM scans in VMDR, matching the given kwargs. Acceptable params are: + +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```scan_ref```|```str```|The reference string of the scan to search for. Formatted like: ```scan/123455677```|❌| +|```state```|```Literal["Running", "Paused", "Cancelled", "Finished", "Error", "Queued", "Loading"]```|Filter by the state of the scan.|❌| +|```processed```|```bool```|Filter by if the scan results have been processed.|❌| +|```type```|```Literal["On-Demand","API","Scheduled]```|Filter by how the scan is set up.|❌| +|```user_login```|```str```|Filter by the Qualys account that created/owns the scan.|❌| +|```launched_after_datetime```|```str```|Filter by scans launched after a specific datetime. Formatted as: ```2007-07-01``` or ```2007-01-25T23:12:00Z```|❌| +|```launched_before_datetime```|```str```|Filter by scans launched before a specific datetime. Formatted as: ```2007-07-01``` or ```2007-01-25T23:12:00Z```|❌| +|```scan_type```|```Literal["certview", "ec2certview"]```|Only return certview scans, or EC2 certview scans.|❌| +|```client_id```|```Union[str,int]```|Filter by the client ID of the scan. This must be enabled in the Qualys subscription.|❌| +|```client_name```|```str```|Filter by the client name of the scan. This must be enabled in the Qualys subscription.|❌| +|```show_ags```|```bool```|Include asset group titles in the scan list.|❌| +|```show_op```|```bool```|Include option profile metadata in the scan list.|❌| +|```show_status```|```bool```|Include status metadata in the scan list. Defaults to ```True```.|❌| +|```show_last```|```bool```|Only show the last run of each scan. Defaults to ```False```.|❌| +|```ignore_target```|```bool```|Ignore the target IPs of the scan. Defaults to ```False```.|❌| + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr import get_scan_list + +auth = BasicAuth(, , platform='qg1') + +#Get all VM scans in VMDR, with all details, that have a type of Scheduled: +scheduled_scans = get_scan_list(auth, type='Scheduled', show_ags=True, show_op=True) +>>>BaseList[VMScan(REF='scan/123456789', TYPE='Scheduled', TITLE='My Scheduled Scan', ...), ...] +``` +--- +### Launch a New Scan API + +```launch_scan()``` is used to create and launch a new VM scan in VMDR. A VMScan object is returned containing the details of the scan once it is created via a ```get_scan_list()``` call with the ```scan_ref``` kwarg set to the newly-created scan reference. Acceptable params are: + +|Parameter| Possible Values |Description|Required| +|--|--|--|--| +|```auth```|```qualyspy.auth.BasicAuth```|The authentication object.|✅| +|```runtime_http_header```|```str```|The value for the ```Qualys.Scan``` HTTP header to use for the scan.|❌| +|```scan_title```|```str```|The title of the scan.|❌| +|```option_id```|```int```|The option profile ID to use for the scan.|⚠️ (Must be specified if ```option_title``` is not specified)| +|```option_title```|```str```|The option profile title to use for the scan.|⚠️ (Must be specified if ```option_id``` is not specified)| +|```ip```|```Union[str, BaseList[str]```|The target IPs for the scan.|⚠️ (Must be specified if one of the following are not specified: ```asset_group_ids```, ```asset_groups```, ```fqdn```)| +|```asset_group_ids```|```Union[str, BaseList[str]```|The asset group IDs to use for the scan.|⚠️ (Must be specified if one of the following are not specified: ```ip```, ```asset_groups```, ```fqdn```)| +|```asset_groups```|```Union[str, BaseList[str]```|The asset group titles to use for the scan.|⚠️ (Must be specified if one of the following are not specified: ```ip```, ```asset_group_ids```, ```fqdn```)| +|```fqdn```|```Union[str, BaseList[str]```|The FQDNs to use for the scan.|⚠️ (Must be specified if one of the following are not specified: ```ip```, ```asset_group_ids```, ```asset_groups```, ```asset_groups```)| +|```iscanner_appliance_id```|```int```|The internal scanner appliance ID to use for the scan.|❌| +|```iscanner_name```|```str```|The internal scanner appliance name to use for the scan.|❌| +|```ec2instance_ids```|```Union[str, BaseList[str]```|The EC2 instance IDs of your external scanners.|❌| +|```exclude_ip_per_scan```|```str, BaseList[str]```|The IPs to exclude from the scan.|❌| +|```default_scanner```|```bool```|Use the default scanner for the scan.|❌| +|```scanners_in_ag```|```bool```|Use the scanners in the asset group for the scan.|❌| +|```target_from```|```Literal["assets", "tags"]```| Choose to target assets based on the assets themselves or based on their tag list.|❌| +|```use_ip_nt_range_tags_include```|```bool```|Use the IP/NT range tags to include in the scan.|❌| +|```use_ip_nt_range_tags_exclude```|```bool```|Use the IP/NT range tags to exclude from the scan.|❌| +|```use_ip_nt_range_tags_include```|```bool```|Use the IP/NT range tags to include in the scan.|❌| +|```tag_selector_include```|```Literal["any", "all"]```| Choose if all tags must match for an asset or any tag can match.|❌| +|```tag_selector_exclude```|```Literal["any", "all"]```| Choose if all tags must match for an asset or any tag can match.|❌| +|```tag_set_by```|```Literal["id", "name"]```| Choose to search for tags by tag ID or tag name.|❌| +|```tag_set_include```|```Union[str, BaseList[str]```|The tags to include in the scan.|❌| +|```tag_set_exclude```|```Union[str, BaseList[str]```|The tags to exclude from the scan.|❌| +|```ip_network_id```|```str```|The IP network ID to use for the scan. Must be enabled in the Qualys subscription.|❌| +|```client_id```|```int```|The client ID to use for the scan. Only valid for consultant subscriptions.|❌| +|```client_name```|```str```|The client name to use for the scan. Only valid for consultant subscriptions.|❌| + +```py +from qualyspy import BasicAuth +from qualyspy.vmdr import launch_scan + +auth = BasicAuth(, , platform='qg1') + +#Launch a new VM scan in VMDR with a specific title and option profile, targeting 2 specific IPs: +result = launch_scan(auth, scan_title='My New Scan', option_id=12345, ip='10.0.0.1,10.0.0.2', iscanner_name='internal_scanner_name') +>>>"New vm scan launched with REF: scan/123456789.12345" +result +>>>VMScan(REF='scan/123456789.12345', TYPE='API', TITLE='My New Scan', ...) +``` +--- +## Querying the KB +The Qualys KnowledgeBase (KB) is a collection of vulnerabilities that Qualys has identified. You can query the KB using the ```query_kb()``` function: +>**Heads Up!**: When calling ```query_kb()```, the function returns a regular list of ```KBEntry``` objects. +```py +from qualyspy import BasicAuth, vmdr + +with BasicAuth(, , platform='qg1') as auth: + #Full KB pull: + kb_query = vmdr.query_kb(auth) + + #or use kwargs to filter, + # for example QIDs published for a specific week: + kb_query = vmdr.query_kb(auth, published_after='2024-06-21', published_before='2024-06-28') + + #Want to search the list of + # KBEntries based on some criteria? + in_scope_qids = [entry for entry in kb_query if entry.QID in range(1000, 2000)] + len(in_scope_qids) +>>>400 +``` + +## Special Dataclasses for VMDR + +There are quite a few special dataclasses that are used in the VMDR module, as well as a ```BaseList``` class that is used to store these dataclasses and add some easier string functionality. + +For example, for KB entries, there is the ```KBEntry``` class which holds the various fields that the Qualys KB API returns. Inside a ```KBEntry``` object there are custom classes for things like ```ThreatIntel``` and ```Software```. Other examples include the ```VMDRHost``` class, which holds the various fields that the VMDR Host List API returns, and the ```Detection``` class, which holds the various fields that the VMDR Host List Detection API returns under a ```VMDRHost```. +```py +... #Prior KB pull + +#Get the ThreatIntel attribute of the a KBEntry object, which is a custom dataclass: +kb_entry.THREAT_INTELLIGENCE +>>>BaseList([ThreatIntel(ID=4, TEXT='High_Lateral_Movement')]) + +#Or perhaps you want all the CVEs in a CVEList as a comma-separated string: +str(kb_entry.CVEList) +>>>'CVE-2024-1234, CVE-2024-5678, ...' +``` + +### KB Dataclasses +|Class| Attributes | +|--|--| +| ```VendorReference``` | ID, URL| +| ```ThreatIntel```| ID, TEXT| +| ```Software``` | PRODUCT, VENDOR | +|```CVEID```| ID, URL | +|```Compliance``` | _TYPE, SECTION, DESCRIPTION | +| ```Bugtraq``` | ID, URL | +----- + +# The CALL_SCHEMA Dictionary +>**TL;DR**: The ```CALL_SCHEMA``` is ***VERY*** important! + +CALL_SCHEMA is a backend ```frozendict``` dictionary that the package automatically uses to correctly set up the underlying ```qualyspy.base.call_api()``` function. ```call_api``` in turn then sets up the appropriate ```requests.Request()``` call using an endpoint's schema. + +The schema stores information such as what HTTP methods the endpoint accepts, the authentication type the endpoint expects, its path, acceptable kwargs when calling an API (and whether a kwarg should be sent as a URL parameter, in a POST form, or using the requests' library's ```json=``` feature), what Qualys URL structure to use (gateway vs. qualysapi), and more. + +The schema also allows all API calls to raise an exception if the user passes in a kwarg that is not valid for an endpoint. + +There are also some values that do not influence program behavior, but are "good-to-knows" for users. See below. + +## Querying the CALL_SCHEMA +If you want to take a look at what an endpoint (or what an entire module's collection of endpoints!) expects, you can do so programmatically: +>**Pro Tip!**: use ```schema_query(...pretty=True)``` to return a beautified string of the query results. + +```py +from qualyspy import schema_query + +#Get one specific endpoint's schema: +print(schema_query(module='gav', endpoint='query_assets')) +>>>{'endpoint': '/am/v1/assets/host/filter/list', 'method': ['POST'], 'valid_params': ['filter', 'excludeFields', 'includeFields', 'lastModifiedDate', 'lastSeenAssetId', 'pageSize'], 'valid_POST_data': [], 'use_requests_json_data': False, 'return_type': 'json', 'pagination': True, 'auth_type': 'token'} + +#Get an entire module's worth of endpoint schemas, +# which includes the module-level url_type: +print(schema_query(module='gav', pretty=True)) +>>>{ + "url_type": "gateway", #Used by call_api() to determine which URL format to use. + "count_assets": { + "endpoint": "/am/v1/assets/host/count", + "method": [ + "POST" + ], + "valid_params": [ + "filter", + "lastSeenAssetId", + "lastModifiedDate" + ], + "valid_POST_data": [], + "use_requests_json_data": false, + "return_type": "json", + "pagination": false, + "auth_type": "token" + }, + "get_all_assets": { + ... + } +} +``` +# TODO: + +- Continue adding VMDR APIs. + +- Start work on PM module. + +- Write testing files. + +- Break up README.md: Move module-specific sections to their respective folders, cleaning up the main README.md. diff --git a/SECURITY.md b/SECURITY.md index 4c4dc5c..f65a64a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,5 @@ -# Security Policy - -## Reporting a Vulnerability - -If you believe you have found a vulnerability in qualyspy, please create an issue/advisory with details/steps to reproduce. +# Security Policy + +## Reporting a Vulnerability + +If you believe you have found a vulnerability in qualyspy, please create an issue/advisory with details/steps to reproduce. diff --git a/poetry.lock b/poetry.lock index 68da035..c3bd729 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,411 +1,411 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - -[[package]] -name = "bs4" -version = "0.0.2" -description = "Dummy package for Beautiful Soup (beautifulsoup4)" -optional = false -python-versions = "*" -files = [ - {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"}, - {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"}, -] - -[package.dependencies] -beautifulsoup4 = "*" - -[[package]] -name = "certifi" -version = "2024.7.4" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, - {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.3.2" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, -] - -[[package]] -name = "frozendict" -version = "2.4.4" -description = "A simple immutable dictionary" -optional = false -python-versions = ">=3.6" -files = [ - {file = "frozendict-2.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a59578d47b3949437519b5c39a016a6116b9e787bb19289e333faae81462e59"}, - {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a342e439aef28ccec533f0253ea53d75fe9102bd6ea928ff530e76eac38906"}, - {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f79c26dff10ce11dad3b3627c89bb2e87b9dd5958c2b24325f16a23019b8b94"}, - {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2bd009cf4fc47972838a91e9b83654dc9a095dc4f2bb3a37c3f3124c8a364543"}, - {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:87ebcde21565a14fe039672c25550060d6f6d88cf1f339beac094c3b10004eb0"}, - {file = "frozendict-2.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:fefeb700bc7eb8b4c2dc48704e4221860d254c8989fb53488540bc44e44a1ac2"}, - {file = "frozendict-2.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:4297d694eb600efa429769125a6f910ec02b85606f22f178bafbee309e7d3ec7"}, - {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:812ab17522ba13637826e65454115a914c2da538356e85f43ecea069813e4b33"}, - {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fee9420475bb6ff357000092aa9990c2f6182b2bab15764330f4ad7de2eae49"}, - {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3148062675536724502c6344d7c485dd4667fdf7980ca9bd05e338ccc0c4471e"}, - {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:78c94991944dd33c5376f720228e5b252ee67faf3bac50ef381adc9e51e90d9d"}, - {file = "frozendict-2.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:1697793b5f62b416c0fc1d94638ec91ed3aa4ab277f6affa3a95216ecb3af170"}, - {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:199a4d32194f3afed6258de7e317054155bc9519252b568d9cfffde7e4d834e5"}, - {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85375ec6e979e6373bffb4f54576a68bf7497c350861d20686ccae38aab69c0a"}, - {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2d8536e068d6bf281f23fa835ac07747fb0f8851879dd189e9709f9567408b4d"}, - {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:259528ba6b56fa051bc996f1c4d8b57e30d6dd3bc2f27441891b04babc4b5e73"}, - {file = "frozendict-2.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:07c3a5dee8bbb84cba770e273cdbf2c87c8e035903af8f781292d72583416801"}, - {file = "frozendict-2.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6874fec816b37b6eb5795b00e0574cba261bf59723e2de607a195d5edaff0786"}, - {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8f92425686323a950337da4b75b4c17a3327b831df8c881df24038d560640d4"}, - {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d58d9a8d9e49662c6dafbea5e641f97decdb3d6ccd76e55e79818415362ba25"}, - {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:93a7b19afb429cbf99d56faf436b45ef2fa8fe9aca89c49eb1610c3bd85f1760"}, - {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b70b431e3a72d410a2cdf1497b3aba2f553635e0c0f657ce311d841bf8273b6"}, - {file = "frozendict-2.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:e1b941132d79ce72d562a13341d38fc217bc1ee24d8c35a20d754e79ff99e038"}, - {file = "frozendict-2.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc2228874eacae390e63fd4f2bb513b3144066a977dc192163c9f6c7f6de6474"}, - {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63aa49f1919af7d45fb8fd5dec4c0859bc09f46880bd6297c79bb2db2969b63d"}, - {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6bf9260018d653f3cab9bd147bd8592bf98a5c6e338be0491ced3c196c034a3"}, - {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6eb716e6a6d693c03b1d53280a1947716129f5ef9bcdd061db5c17dea44b80fe"}, - {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d13b4310db337f4d2103867c5a05090b22bc4d50ca842093779ef541ea9c9eea"}, - {file = "frozendict-2.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:b3b967d5065872e27b06f785a80c0ed0a45d1f7c9b85223da05358e734d858ca"}, - {file = "frozendict-2.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:4ae8d05c8d0b6134bfb6bfb369d5fa0c4df21eabb5ca7f645af95fdc6689678e"}, - {file = "frozendict-2.4.4-py311-none-any.whl", hash = "sha256:705efca8d74d3facbb6ace80ab3afdd28eb8a237bfb4063ed89996b024bc443d"}, - {file = "frozendict-2.4.4-py312-none-any.whl", hash = "sha256:d9647563e76adb05b7cde2172403123380871360a114f546b4ae1704510801e5"}, - {file = "frozendict-2.4.4.tar.gz", hash = "sha256:3f7c031b26e4ee6a3f786ceb5e3abf1181c4ade92dce1f847da26ea2c96008c7"}, -] - -[[package]] -name = "idna" -version = "3.7" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, - {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, -] - -[[package]] -name = "lxml" -version = "5.2.2" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=3.6" -files = [ - {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632"}, - {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f"}, - {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393"}, - {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526"}, - {file = "lxml-5.2.2-cp310-cp310-win32.whl", hash = "sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30"}, - {file = "lxml-5.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7"}, - {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545"}, - {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d"}, - {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa"}, - {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b"}, - {file = "lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438"}, - {file = "lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be"}, - {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391"}, - {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466"}, - {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c"}, - {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836"}, - {file = "lxml-5.2.2-cp312-cp312-win32.whl", hash = "sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a"}, - {file = "lxml-5.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48"}, - {file = "lxml-5.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce"}, - {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56"}, - {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9"}, - {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264"}, - {file = "lxml-5.2.2-cp36-cp36m-win32.whl", hash = "sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3"}, - {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, - {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"}, - {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"}, - {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, - {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, - {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, - {file = "lxml-5.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5"}, - {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b"}, - {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1"}, - {file = "lxml-5.2.2-cp38-cp38-win32.whl", hash = "sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30"}, - {file = "lxml-5.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6"}, - {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30"}, - {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a"}, - {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472"}, - {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9"}, - {file = "lxml-5.2.2-cp39-cp39-win32.whl", hash = "sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf"}, - {file = "lxml-5.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2"}, - {file = "lxml-5.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8"}, - {file = "lxml-5.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db"}, - {file = "lxml-5.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a"}, - {file = "lxml-5.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324"}, - {file = "lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html-clean = ["lxml-html-clean"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.10)"] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "soupsieve" -version = "2.5" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -files = [ - {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, - {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, -] - -[[package]] -name = "urllib3" -version = "2.2.2" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.11" -content-hash = "304cc1df9d739cbec9fa2f29cfe8651fc55a7ed74a6ea58214923a7ffcf4b132" +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "bs4" +version = "0.0.2" +description = "Dummy package for Beautiful Soup (beautifulsoup4)" +optional = false +python-versions = "*" +files = [ + {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"}, + {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"}, +] + +[package.dependencies] +beautifulsoup4 = "*" + +[[package]] +name = "certifi" +version = "2024.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "frozendict" +version = "2.4.4" +description = "A simple immutable dictionary" +optional = false +python-versions = ">=3.6" +files = [ + {file = "frozendict-2.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a59578d47b3949437519b5c39a016a6116b9e787bb19289e333faae81462e59"}, + {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a342e439aef28ccec533f0253ea53d75fe9102bd6ea928ff530e76eac38906"}, + {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f79c26dff10ce11dad3b3627c89bb2e87b9dd5958c2b24325f16a23019b8b94"}, + {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2bd009cf4fc47972838a91e9b83654dc9a095dc4f2bb3a37c3f3124c8a364543"}, + {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:87ebcde21565a14fe039672c25550060d6f6d88cf1f339beac094c3b10004eb0"}, + {file = "frozendict-2.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:fefeb700bc7eb8b4c2dc48704e4221860d254c8989fb53488540bc44e44a1ac2"}, + {file = "frozendict-2.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:4297d694eb600efa429769125a6f910ec02b85606f22f178bafbee309e7d3ec7"}, + {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:812ab17522ba13637826e65454115a914c2da538356e85f43ecea069813e4b33"}, + {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fee9420475bb6ff357000092aa9990c2f6182b2bab15764330f4ad7de2eae49"}, + {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3148062675536724502c6344d7c485dd4667fdf7980ca9bd05e338ccc0c4471e"}, + {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:78c94991944dd33c5376f720228e5b252ee67faf3bac50ef381adc9e51e90d9d"}, + {file = "frozendict-2.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:1697793b5f62b416c0fc1d94638ec91ed3aa4ab277f6affa3a95216ecb3af170"}, + {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:199a4d32194f3afed6258de7e317054155bc9519252b568d9cfffde7e4d834e5"}, + {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85375ec6e979e6373bffb4f54576a68bf7497c350861d20686ccae38aab69c0a"}, + {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2d8536e068d6bf281f23fa835ac07747fb0f8851879dd189e9709f9567408b4d"}, + {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:259528ba6b56fa051bc996f1c4d8b57e30d6dd3bc2f27441891b04babc4b5e73"}, + {file = "frozendict-2.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:07c3a5dee8bbb84cba770e273cdbf2c87c8e035903af8f781292d72583416801"}, + {file = "frozendict-2.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6874fec816b37b6eb5795b00e0574cba261bf59723e2de607a195d5edaff0786"}, + {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8f92425686323a950337da4b75b4c17a3327b831df8c881df24038d560640d4"}, + {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d58d9a8d9e49662c6dafbea5e641f97decdb3d6ccd76e55e79818415362ba25"}, + {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:93a7b19afb429cbf99d56faf436b45ef2fa8fe9aca89c49eb1610c3bd85f1760"}, + {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b70b431e3a72d410a2cdf1497b3aba2f553635e0c0f657ce311d841bf8273b6"}, + {file = "frozendict-2.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:e1b941132d79ce72d562a13341d38fc217bc1ee24d8c35a20d754e79ff99e038"}, + {file = "frozendict-2.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc2228874eacae390e63fd4f2bb513b3144066a977dc192163c9f6c7f6de6474"}, + {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63aa49f1919af7d45fb8fd5dec4c0859bc09f46880bd6297c79bb2db2969b63d"}, + {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6bf9260018d653f3cab9bd147bd8592bf98a5c6e338be0491ced3c196c034a3"}, + {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6eb716e6a6d693c03b1d53280a1947716129f5ef9bcdd061db5c17dea44b80fe"}, + {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d13b4310db337f4d2103867c5a05090b22bc4d50ca842093779ef541ea9c9eea"}, + {file = "frozendict-2.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:b3b967d5065872e27b06f785a80c0ed0a45d1f7c9b85223da05358e734d858ca"}, + {file = "frozendict-2.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:4ae8d05c8d0b6134bfb6bfb369d5fa0c4df21eabb5ca7f645af95fdc6689678e"}, + {file = "frozendict-2.4.4-py311-none-any.whl", hash = "sha256:705efca8d74d3facbb6ace80ab3afdd28eb8a237bfb4063ed89996b024bc443d"}, + {file = "frozendict-2.4.4-py312-none-any.whl", hash = "sha256:d9647563e76adb05b7cde2172403123380871360a114f546b4ae1704510801e5"}, + {file = "frozendict-2.4.4.tar.gz", hash = "sha256:3f7c031b26e4ee6a3f786ceb5e3abf1181c4ade92dce1f847da26ea2c96008c7"}, +] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[package]] +name = "lxml" +version = "5.2.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.6" +files = [ + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632"}, + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526"}, + {file = "lxml-5.2.2-cp310-cp310-win32.whl", hash = "sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30"}, + {file = "lxml-5.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b"}, + {file = "lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438"}, + {file = "lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836"}, + {file = "lxml-5.2.2-cp312-cp312-win32.whl", hash = "sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a"}, + {file = "lxml-5.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48"}, + {file = "lxml-5.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264"}, + {file = "lxml-5.2.2-cp36-cp36m-win32.whl", hash = "sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3"}, + {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, + {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, + {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, + {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, + {file = "lxml-5.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1"}, + {file = "lxml-5.2.2-cp38-cp38-win32.whl", hash = "sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30"}, + {file = "lxml-5.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9"}, + {file = "lxml-5.2.2-cp39-cp39-win32.whl", hash = "sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf"}, + {file = "lxml-5.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324"}, + {file = "lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.10)"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + +[[package]] +name = "urllib3" +version = "2.2.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "304cc1df9d739cbec9fa2f29cfe8651fc55a7ed74a6ea58214923a7ffcf4b132" diff --git a/pyproject.toml b/pyproject.toml index 9c917f7..93dd30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,19 @@ -[tool.poetry] -name = "qualyspy" -version = "0.1.0" -description = "SDK for interacting with Qualys APIs, across most of modules the platform offers." -authors = ["0x41424142 ", "0x4A616B65 "] -readme = "README.md" -license = "MIT License" - -[tool.poetry.dependencies] -python = "^3.11" -requests = "^2.32.3" -frozendict = "^2.4.4" -bs4 = "^0.0.2" -lxml = "^5.2.2" - - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.poetry] +name = "qualyspy" +version = "0.1.0" +description = "SDK for interacting with Qualys APIs, across most of modules the platform offers." +authors = ["0x41424142 ", "0x4A616B65 "] +readme = "README.md" +license = "MIT License" + +[tool.poetry.dependencies] +python = "^3.11" +requests = "^2.32.3" +frozendict = "^2.4.4" +bs4 = "^0.0.2" +lxml = "^5.2.2" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/qualyspy/__init__.py b/qualyspy/__init__.py index a1c7d4b..1af1f7d 100644 --- a/qualyspy/__init__.py +++ b/qualyspy/__init__.py @@ -1,16 +1,16 @@ -""" -Qualys API - Qualys API SDK for Python - -This package aims to make it easier to interact with the Qualys API across all of the different modules that Qualys provides. -""" - -from .help import schema_query -from .auth import BasicAuth, TokenAuth - -from .gav.uber import GAVUber - -from .vmdr import query_kb, get_host_list, get_hld - - -# surprise! -__surprise__ = b"\xe2\x9c\xa8\xe2\x9c\xa8\xe2\x9c\xa8 Have a great day!".decode("utf-8") +""" +Qualys API - Qualys API SDK for Python + +This package aims to make it easier to interact with the Qualys API across all of the different modules that Qualys provides. +""" + +from .help import schema_query +from .auth import BasicAuth, TokenAuth + +from .gav.uber import GAVUber + +from .vmdr import query_kb, get_host_list, get_hld + + +# surprise! +__surprise__ = b"\xe2\x9c\xa8\xe2\x9c\xa8\xe2\x9c\xa8 Have a great day!".decode("utf-8") diff --git a/qualyspy/auth/__init__.py b/qualyspy/auth/__init__.py index 50e1c2f..b5513a6 100644 --- a/qualyspy/auth/__init__.py +++ b/qualyspy/auth/__init__.py @@ -1,14 +1,14 @@ -""" -Authentication submodule for QualysAPI. Contains classes for handling authentication. - -Base class is Authentication - -There are two subclasses of Authentication: -- BasicAuth -- TokenAuth - -BasicAuth handles HTTP Basic Authentication, while TokenAuth handles JWT token authentication. -""" - -from .basic import BasicAuth -from .token import TokenAuth +""" +Authentication submodule for QualysAPI. Contains classes for handling authentication. + +Base class is Authentication + +There are two subclasses of Authentication: +- BasicAuth +- TokenAuth + +BasicAuth handles HTTP Basic Authentication, while TokenAuth handles JWT token authentication. +""" + +from .basic import BasicAuth +from .token import TokenAuth diff --git a/qualyspy/auth/base.py b/qualyspy/auth/base.py index 74f3cf1..c1864ef 100644 --- a/qualyspy/auth/base.py +++ b/qualyspy/auth/base.py @@ -1,110 +1,110 @@ -""" -base.py - base authentication class for QualysAPI -""" - -import json -from dataclasses import dataclass, field -from typing import Optional, Literal, Self - - -from ..exceptions import ( - InvalidCredentialsError, - InvalidTokenError, - InvalidAuthTypeError, -) - - -@dataclass -class BaseAuthentication: - """ - Base class for authentication with QualysAPI. - """ - - username: str - password: str = field( - repr=False - ) # Hide password from repr. If the user wants to see it that badly, they can do so manually with the password attribute. - token: Optional[str] = field(default=None, repr=False) # same goes for token ^^ - auth_type: Literal["basic", "token"] = field(init=False) - - def __post_init__(self) -> None: - """ - Post-init method to determine auth_type based on if a token is passed or not. - """ - if self.token is None: - self.auth_type = "basic" - else: - self.auth_type = "token" - - def __str__(self) -> str: - """ - String representation of the authentication object. - """ - return f"Base authentication object for {self.username}" - - def validate_type(self): - """ - Validate the authentication object. - """ - if self.auth_type == "basic": - if self.username is None or self.password is None: - raise InvalidCredentialsError( - "Username and password must be provided for basic authentication." - ) - elif self.auth_type == "token": - if self.token is None: - raise InvalidTokenError( - "Token must be provided for token authentication." - ) - else: - raise InvalidAuthTypeError( - "Invalid authentication type. Must be 'basic' or 'token'." - ) - - def to_dict(self) -> dict: - """ - Convert the authentication object to a dictionary. - """ - return { - "username": self.username, - "password": self.password, - "token": self.token, - "auth_type": self.auth_type, - } - - @classmethod - def from_dict(cls, data: dict): - """ - Create an authentication object from a dictionary. - """ - return cls(**data) - - @classmethod - def from_json_string(cls, data: str): - """ - Create an authentication object from a JSON string. - """ - return cls.from_dict(json.loads(data)) - - def to_json_string(self, pretty: bool = False) -> str: - """ - Convert the authentication object to a JSON string. - - Args: - ``` - pretty: bool (default is False) - whether to pretty print the JSON string or not. - ``` - """ - return json.dumps(self.to_dict(), indent=4 if pretty else None) - - def __eq__(self, other): - """ - Equality comparison for authentication objects. - """ - return self.to_dict() == other.to_dict() - - def __ne__(self, other): - """ - Inequality comparison for authentication objects. - """ - return self.to_dict() != other.to_dict() +""" +base.py - base authentication class for QualysAPI +""" + +import json +from dataclasses import dataclass, field +from typing import Optional, Literal, Self + + +from ..exceptions import ( + InvalidCredentialsError, + InvalidTokenError, + InvalidAuthTypeError, +) + + +@dataclass +class BaseAuthentication: + """ + Base class for authentication with QualysAPI. + """ + + username: str + password: str = field( + repr=False + ) # Hide password from repr. If the user wants to see it that badly, they can do so manually with the password attribute. + token: Optional[str] = field(default=None, repr=False) # same goes for token ^^ + auth_type: Literal["basic", "token"] = field(init=False) + + def __post_init__(self) -> None: + """ + Post-init method to determine auth_type based on if a token is passed or not. + """ + if self.token is None: + self.auth_type = "basic" + else: + self.auth_type = "token" + + def __str__(self) -> str: + """ + String representation of the authentication object. + """ + return f"Base authentication object for {self.username}" + + def validate_type(self): + """ + Validate the authentication object. + """ + if self.auth_type == "basic": + if self.username is None or self.password is None: + raise InvalidCredentialsError( + "Username and password must be provided for basic authentication." + ) + elif self.auth_type == "token": + if self.token is None: + raise InvalidTokenError( + "Token must be provided for token authentication." + ) + else: + raise InvalidAuthTypeError( + "Invalid authentication type. Must be 'basic' or 'token'." + ) + + def to_dict(self) -> dict: + """ + Convert the authentication object to a dictionary. + """ + return { + "username": self.username, + "password": self.password, + "token": self.token, + "auth_type": self.auth_type, + } + + @classmethod + def from_dict(cls, data: dict): + """ + Create an authentication object from a dictionary. + """ + return cls(**data) + + @classmethod + def from_json_string(cls, data: str): + """ + Create an authentication object from a JSON string. + """ + return cls.from_dict(json.loads(data)) + + def to_json_string(self, pretty: bool = False) -> str: + """ + Convert the authentication object to a JSON string. + + Params: + ``` + pretty: bool (default is False) - whether to pretty print the JSON string or not. + ``` + """ + return json.dumps(self.to_dict(), indent=4 if pretty else None) + + def __eq__(self, other): + """ + Equality comparison for authentication objects. + """ + return self.to_dict() == other.to_dict() + + def __ne__(self, other): + """ + Inequality comparison for authentication objects. + """ + return self.to_dict() != other.to_dict() diff --git a/qualyspy/auth/basic.py b/qualyspy/auth/basic.py index 0c1a265..f85252c 100644 --- a/qualyspy/auth/basic.py +++ b/qualyspy/auth/basic.py @@ -1,117 +1,117 @@ -""" -basic.py - contains the BasicAuth class, which handles API endpoints that require basic authentication -""" - -from dataclasses import dataclass, field -from typing import Literal, Union - -from requests import get - -from .base import BaseAuthentication -from ..exceptions import AuthenticationError - - -@dataclass -class BasicAuth(BaseAuthentication): - """ - BasicAuth - handles API endpoints that require basic authentication - - Subclass of .base.BaseAuthentication - provides the basic authentication for the API - - Attributes: - ``` - platform: str - the platform for the basic authentication. Defaults to "qg3", but can be "qg[1-4]" - ``` - Other attributes are inherited from BaseAuthentication - AKA username, password, token, and auth_type - """ - - platform: Literal["qg1", "qg2", "qg3", "qg4"] = field(default="qg3", init=True) - - def __post_init__(self) -> None: - """ - Post-init method to determine auth_type based on if a token is passed or not. - """ - if self.platform not in ["qg1", "qg2", "qg3", "qg4"]: - raise ValueError("Platform must be one of 'qg1', 'qg2', 'qg3', or 'qg4'.") - - super().__post_init__() - self.validate_type() - # self.auth_type = "basic" - if self.auth_type == "basic": # account for TokenAuth - self.test_login() - - def __str__(self) -> str: - """ - String representation of the authentication object. - """ - return f"Basic authentication object for {self.username} on {self.platform} platform." - - def to_dict(self) -> dict: - """ - Convert the authentication object to a dictionary. - """ - return { - "username": self.username, - "password": self.password, - "token": self.token, - "auth_type": self.auth_type, - "platform": self.platform, - } - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - pass - - def test_login(self, return_ratelimit: bool = False) -> Union[dict, None]: - """ - Get the rate limit for the API. - - Args: - ``` - return_ratelimit: bool (default is False) - whether to return the rate limit details as a dict or not. - You should call get_ratelimit() to get the rate limit details. - ``` - - Returns: - ``` - { - "X-RateLimit-Remaining": int, - "X-RateLimit-Limit": int, - "X-Concurrency-Limit-Limit": int, - "X-RateLimit-ToWait-Sec": int - } - """ - - ( - print( - f"Testing login for {self.username} on {self.platform} platform via {self.auth_type} authentication." - ) - if not return_ratelimit - else None - ) - url = f"https://qualysapi.{self.platform}.apps.qualys.com/msp/about.php" - - """Requires basic auth. JWT is not supported for this endpoint.""" - r = get(url, auth=(self.username, self.password)) - - if r.status_code != 200: - raise AuthenticationError( - f"Failed to authenticate. Requests reporting: {r.text}" - ) - - rl = { - # "X-RateLimit-Remaining": int(r.headers["X-RateLimit-Remaining"]), - "X-RateLimit-Limit": int(r.headers["X-RateLimit-Limit"]), - "X-Concurrency-Limit-Limit": int(r.headers["X-Concurrency-Limit-Limit"]), - # "X-RateLimit-ToWait-Sec": int(r.headers["X-RateLimit-ToWait-Sec"]), - } - print(f"Success. Rate limit details: {rl}") if not return_ratelimit else None - return rl if return_ratelimit else None - - def get_ratelimit(self) -> dict: - """ - "Return ratelimit details for the API. - """ - return self.test_login(return_ratelimit=True) +""" +basic.py - contains the BasicAuth class, which handles API endpoints that require basic authentication +""" + +from dataclasses import dataclass, field +from typing import Literal, Union + +from requests import get + +from .base import BaseAuthentication +from ..exceptions import AuthenticationError + + +@dataclass +class BasicAuth(BaseAuthentication): + """ + BasicAuth - handles API endpoints that require basic authentication + + Subclass of .base.BaseAuthentication - provides the basic authentication for the API + + Attributes: + ``` + platform: str - the platform for the basic authentication. Defaults to "qg3", but can be "qg[1-4]" + ``` + Other attributes are inherited from BaseAuthentication - AKA username, password, token, and auth_type + """ + + platform: Literal["qg1", "qg2", "qg3", "qg4"] = field(default="qg3", init=True) + + def __post_init__(self) -> None: + """ + Post-init method to determine auth_type based on if a token is passed or not. + """ + if self.platform not in ["qg1", "qg2", "qg3", "qg4"]: + raise ValueError("Platform must be one of 'qg1', 'qg2', 'qg3', or 'qg4'.") + + super().__post_init__() + self.validate_type() + # self.auth_type = "basic" + if self.auth_type == "basic": # account for TokenAuth + self.test_login() + + def __str__(self) -> str: + """ + String representation of the authentication object. + """ + return f"Basic authentication object for {self.username} on {self.platform} platform." + + def to_dict(self) -> dict: + """ + Convert the authentication object to a dictionary. + """ + return { + "username": self.username, + "password": self.password, + "token": self.token, + "auth_type": self.auth_type, + "platform": self.platform, + } + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + pass + + def test_login(self, return_ratelimit: bool = False) -> Union[dict, None]: + """ + Get the rate limit for the API. + + Params: + ``` + return_ratelimit: bool (default is False) - whether to return the rate limit details as a dict or not. + You should call get_ratelimit() to get the rate limit details. + ``` + + Returns: + ``` + { + "X-RateLimit-Remaining": int, + "X-RateLimit-Limit": int, + "X-Concurrency-Limit-Limit": int, + "X-RateLimit-ToWait-Sec": int + } + """ + + ( + print( + f"Testing login for {self.username} on {self.platform} platform via {self.auth_type} authentication." + ) + if not return_ratelimit + else None + ) + url = f"https://qualysapi.{self.platform}.apps.qualys.com/msp/about.php" + + """Requires basic auth. JWT is not supported for this endpoint.""" + r = get(url, auth=(self.username, self.password)) + + if r.status_code != 200: + raise AuthenticationError( + f"Failed to authenticate. Requests reporting: {r.text}" + ) + + rl = { + # "X-RateLimit-Remaining": int(r.headers["X-RateLimit-Remaining"]), + "X-RateLimit-Limit": int(r.headers["X-RateLimit-Limit"]), + "X-Concurrency-Limit-Limit": int(r.headers["X-Concurrency-Limit-Limit"]), + # "X-RateLimit-ToWait-Sec": int(r.headers["X-RateLimit-ToWait-Sec"]), + } + print(f"Success. Rate limit details: {rl}") if not return_ratelimit else None + return rl if return_ratelimit else None + + def get_ratelimit(self) -> dict: + """ + "Return ratelimit details for the API. + """ + return self.test_login(return_ratelimit=True) diff --git a/qualyspy/auth/token.py b/qualyspy/auth/token.py index 699b624..d7c6b87 100644 --- a/qualyspy/auth/token.py +++ b/qualyspy/auth/token.py @@ -1,85 +1,85 @@ -""" -token.py - contains the TokenAuth class, which handles API endpoints that require JWT authentication -""" - -from dataclasses import dataclass, field -from datetime import datetime - -from requests import post - -from .basic import BasicAuth -from ..exceptions import AuthenticationError - - -@dataclass -class TokenAuth(BasicAuth): - """ - TokenAuth - handles API endpoints that require JWT authentication. - This class will take the username, password, and platform attributes from BasicAuth and use them to generate a JWT token via the Qualys JWT-generatio API. - - Attributes: - ``` - token: str - the JWT token for the API - generated_on: datetime - the datetime the token was generated. Tokens are valid for 4 hours. - ``` - Subclass of .basic.BasicAuth - provides the JWT authentication for the API - """ - - # JWT token - token: str = field(init=False, repr=False) - generated_on: datetime = field(init=False, default=datetime) - - def __post_init__(self): - """ - generates the JWT token for the API - """ - self.auth_type = "token" - self.token = "placeholder" - super().__post_init__() - self.token = self.get_token() - - def get_token(self) -> str: - """ - generates the JWT token from the Qualys API - """ - url = f"https://gateway.{self.platform}.apps.qualys.com/auth" - - payload = { - "username": self.username, - "password": self.password, - "permissions": "true", - "token": "true", - } - - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - print(f"Generating token for {self.username} on {self.platform} platform.") - - r = post(url, headers=headers, data=payload) - - if r.status_code != 201: - raise AuthenticationError( - f"Failed to generate token. Requests reporting: {r.text}" - ) - print("Success.") - self.generated_on = datetime.now() - return r.text - - def as_header(self) -> dict: - """ - returns the headers for the API request - """ - return {"Authorization": f"Bearer {self.token}"} - - @classmethod - def from_dict(cls, data: dict): - """ - provided the dictionary keys 'username', 'password', and 'platform' are present, - creates a TokenAuth object from the dictionary - """ - if all(key in data for key in ["username", "password"]): - return cls(data["username"], data["password"]) - else: - raise AuthenticationError( - "Missing required keys 'username' or 'password' in dictionary." - ) +""" +token.py - contains the TokenAuth class, which handles API endpoints that require JWT authentication +""" + +from dataclasses import dataclass, field +from datetime import datetime + +from requests import post + +from .basic import BasicAuth +from ..exceptions import AuthenticationError + + +@dataclass +class TokenAuth(BasicAuth): + """ + TokenAuth - handles API endpoints that require JWT authentication. + This class will take the username, password, and platform attributes from BasicAuth and use them to generate a JWT token via the Qualys JWT-generatio API. + + Attributes: + ``` + token: str - the JWT token for the API + generated_on: datetime - the datetime the token was generated. Tokens are valid for 4 hours. + ``` + Subclass of .basic.BasicAuth - provides the JWT authentication for the API + """ + + # JWT token + token: str = field(init=False, repr=False) + generated_on: datetime = field(init=False, default=datetime) + + def __post_init__(self): + """ + generates the JWT token for the API + """ + self.auth_type = "token" + self.token = "placeholder" + super().__post_init__() + self.token = self.get_token() + + def get_token(self) -> str: + """ + generates the JWT token from the Qualys API + """ + url = f"https://gateway.{self.platform}.apps.qualys.com/auth" + + payload = { + "username": self.username, + "password": self.password, + "permissions": "true", + "token": "true", + } + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + print(f"Generating token for {self.username} on {self.platform} platform.") + + r = post(url, headers=headers, data=payload) + + if r.status_code != 201: + raise AuthenticationError( + f"Failed to generate token. Requests reporting: {r.text}" + ) + print("Success.") + self.generated_on = datetime.now() + return r.text + + def as_header(self) -> dict: + """ + returns the headers for the API request + """ + return {"Authorization": f"Bearer {self.token}"} + + @classmethod + def from_dict(cls, data: dict): + """ + provided the dictionary keys 'username', 'password', and 'platform' are present, + creates a TokenAuth object from the dictionary + """ + if all(key in data for key in ["username", "password"]): + return cls(data["username"], data["password"]) + else: + raise AuthenticationError( + "Missing required keys 'username' or 'password' in dictionary." + ) diff --git a/qualyspy/base/__init__.py b/qualyspy/base/__init__.py index 6bad465..ba988ce 100644 --- a/qualyspy/base/__init__.py +++ b/qualyspy/base/__init__.py @@ -1,7 +1,7 @@ -""" -Basic functionality / helpers for QualysAPI. -""" - -from .call_api import call_api -from .call_schema import CALL_SCHEMA -from .xml_parser import xml_parser +""" +Basic functionality / helpers for QualysAPI. +""" + +from .call_api import call_api +from .call_schema import CALL_SCHEMA +from .xml_parser import xml_parser diff --git a/qualyspy/base/call_api.py b/qualyspy/base/call_api.py index bdf790e..f446f0f 100644 --- a/qualyspy/base/call_api.py +++ b/qualyspy/base/call_api.py @@ -1,153 +1,153 @@ -""" -call_api.py - contains the call_api function for the QualysAPI package. - -This function handles all API calls to the Qualys API. It takes in a URL, headers, and a payload, and returns the response from the API. -Qualys uses many tricks in their API, such as using both url params and post data. -""" - -from requests import request, Response -from typing import Literal, Union -from datetime import datetime - -from ..auth.token import TokenAuth -from ..auth.basic import BasicAuth -from ..exceptions.Exceptions import * -from .call_schema import CALL_SCHEMA -from .convert_bools_and_nones import convert_bools_and_nones -from .xml_parser import xml_parser - - -def call_api( - auth: Union[BasicAuth, TokenAuth], - module: str, - endpoint: str, - headers: dict = None, - params: dict = None, - payload: dict = None, - jsonbody: dict = None, - override_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = None, -) -> Response: - """ - Base call function for the Qualys API. - - This function is used across all modules and endpoints as the actual - API call function. - - Args: - ``` - auth (Union[BasicAuth, TokenAuth]) The authentication object. - module (str) The module to call using the CALL_SCHEMA. - endpoint (str) The endpoint to call using the CALL_SCHEMA. - headers (dict) The headers to send. - payload (dict) The payload to send. - params (dict) The parameters to send. - jsonbody (dict) The JSON body to send. - override_method (Literal["GET", "POST", "PUT", "PATCH", "DELETE"]) The method to override the schema with. - ``` - """ - - # check module and endpoint: - if module.lower() not in CALL_SCHEMA.keys(): - raise ValueError( - f"Invalid module {module}. Valid modules are: {CALL_SCHEMA.keys()}." - ) - if endpoint.lower() not in CALL_SCHEMA[module].keys(): - raise ValueError( - f"Invalid endpoint {endpoint} for module {module}. Valid endpoints are: {[i for i in CALL_SCHEMA[module].keys() if i != 'url_type']}." - ) - - SCHEMA = CALL_SCHEMA[module][endpoint] - - # check the auth type: - if SCHEMA["auth_type"] != auth.auth_type: - raise AuthTypeMismatchError( - f"Auth type mismatch. Expected {SCHEMA['auth_type']} but got {auth.auth_type}." - ) - - # check override: - if override_method: - if override_method.upper() not in SCHEMA["method"]: - raise ValueError( - f"Invalid override method {override_method}. Valid methods are: {SCHEMA['method']}." - ) - - # match the url_type to get the proper template: - match CALL_SCHEMA[module]["url_type"]: - case "gateway": - url = f"https://gateway.{auth.platform}.apps.qualys.com{SCHEMA['endpoint']}" - case "api": - url = ( - f"https://qualysapi.{auth.platform}.apps.qualys.com{SCHEMA['endpoint']}" - ) - case _: - raise ValueError(f"Invalid url_type {SCHEMA['url_type']}.") - - # if token auth, check if token is not 4+ hours old: - if isinstance(auth, TokenAuth): - # check that the time delta between now and the token generation time is less than 4 hours: - if (datetime.now() - auth.generated_on).seconds > 14400: - print("Token is 4+ hours old. Refreshing token.") - auth.get_token() - - # check params: - if params: - for key in params.keys(): - if key not in SCHEMA["valid_params"]: - raise ValueError( - f"Invalid parameter {key} for {module}-{endpoint}. Valid parameters are: {SCHEMA['valid_params']}." - ) - - # check post data: - if payload: - for key in payload.keys(): - if key not in SCHEMA["valid_POST_data"]: - raise ValueError(f"Invalid payload key {key} for {module}-{endpoint}.") - - # check if data should be POSTed as requests.post(json=): - if SCHEMA["use_requests_json_data"]: - use_json = True - else: - use_json = False - - # set up JWT auth header if needed: - if auth.auth_type == "token": - headers = auth.as_header() - # or set up the tuple for basic auth: - elif auth.auth_type == "basic": - auth_tuple = (auth.username, auth.password) - - # Make certain payloads/params requests-friendly: - if payload: - payload = convert_bools_and_nones(payload) - if params: - params = convert_bools_and_nones(params) - - # and finally, make the request: - response = request( - method=SCHEMA["method"][0] if not override_method else override_method.upper(), - url=url, - headers=headers, - params=params, - data=payload if not use_json else None, - json=jsonbody if use_json else None, - auth=(auth_tuple if auth.auth_type == "basic" else None), - ) - - # check for errors: - if response.status_code in range(400, 599): - # print(f"Error: {response.text}") - # response.raise_for_status() - parsed = xml_parser(response.text) - # Common path is [SIMPLE_RETURN][RESPONSE][TEXT] - if "SIMPLE_RETURN" in parsed: - raise QualysAPIError(parsed["SIMPLE_RETURN"]["RESPONSE"]["TEXT"]) - elif "html" in parsed: - raise Exception( - f"Error: {parsed['html']['body']['h1']}: {parsed['html']['body']['p'][1]['#text']}" - ) - else: - raise Exception( - f"Error: {parsed['{http://www.w3.org/1999/xhtml}html']['{http://www.w3.org/1999/xhtml}body']['{http://www.w3.org/1999/xhtml}h1']}" - ) - - return response +""" +call_api.py - contains the call_api function for the QualysAPI package. + +This function handles all API calls to the Qualys API. It takes in a URL, headers, and a payload, and returns the response from the API. +Qualys uses many tricks in their API, such as using both url params and post data. +""" + +from requests import request, Response +from typing import Literal, Union +from datetime import datetime + +from ..auth.token import TokenAuth +from ..auth.basic import BasicAuth +from ..exceptions.Exceptions import * +from .call_schema import CALL_SCHEMA +from .convert_bools_and_nones import convert_bools_and_nones +from .xml_parser import xml_parser + + +def call_api( + auth: Union[BasicAuth, TokenAuth], + module: str, + endpoint: str, + headers: dict = None, + params: dict = None, + payload: dict = None, + jsonbody: dict = None, + override_method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = None, +) -> Response: + """ + Base call function for the Qualys API. + + This function is used across all modules and endpoints as the actual + API call function. + + Params: + ``` + auth (Union[BasicAuth, TokenAuth]) The authentication object. + module (str) The module to call using the CALL_SCHEMA. + endpoint (str) The endpoint to call using the CALL_SCHEMA. + headers (dict) The headers to send. + payload (dict) The payload to send. + params (dict) The parameters to send. + jsonbody (dict) The JSON body to send. + override_method (Literal["GET", "POST", "PUT", "PATCH", "DELETE"]) The method to override the schema with. + ``` + """ + + # check module and endpoint: + if module.lower() not in CALL_SCHEMA.keys(): + raise ValueError( + f"Invalid module {module}. Valid modules are: {CALL_SCHEMA.keys()}." + ) + if endpoint.lower() not in CALL_SCHEMA[module].keys(): + raise ValueError( + f"Invalid endpoint {endpoint} for module {module}. Valid endpoints are: {[i for i in CALL_SCHEMA[module].keys() if i != 'url_type']}." + ) + + SCHEMA = CALL_SCHEMA[module][endpoint] + + # check the auth type: + if SCHEMA["auth_type"] != auth.auth_type: + raise AuthTypeMismatchError( + f"Auth type mismatch. Expected {SCHEMA['auth_type']} but got {auth.auth_type}." + ) + + # check override: + if override_method: + if override_method.upper() not in SCHEMA["method"]: + raise ValueError( + f"Invalid override method {override_method}. Valid methods are: {SCHEMA['method']}." + ) + + # match the url_type to get the proper template: + match CALL_SCHEMA[module]["url_type"]: + case "gateway": + url = f"https://gateway.{auth.platform}.apps.qualys.com{SCHEMA['endpoint']}" + case "api": + url = ( + f"https://qualysapi.{auth.platform}.apps.qualys.com{SCHEMA['endpoint']}" + ) + case _: + raise ValueError(f"Invalid url_type {SCHEMA['url_type']}.") + + # if token auth, check if token is not 4+ hours old: + if isinstance(auth, TokenAuth): + # check that the time delta between now and the token generation time is less than 4 hours: + if (datetime.now() - auth.generated_on).seconds > 14400: + print("Token is 4+ hours old. Refreshing token.") + auth.get_token() + + # check params: + if params: + for key in params.keys(): + if key not in SCHEMA["valid_params"]: + raise ValueError( + f"Invalid parameter {key} for {module}-{endpoint}. Valid parameters are: {SCHEMA['valid_params']}." + ) + + # check post data: + if payload: + for key in payload.keys(): + if key not in SCHEMA["valid_POST_data"]: + raise ValueError(f"Invalid payload key {key} for {module}-{endpoint}.") + + # check if data should be POSTed as requests.post(json=): + if SCHEMA["use_requests_json_data"]: + use_json = True + else: + use_json = False + + # set up JWT auth header if needed: + if auth.auth_type == "token": + headers = auth.as_header() + # or set up the tuple for basic auth: + elif auth.auth_type == "basic": + auth_tuple = (auth.username, auth.password) + + # Make certain payloads/params requests-friendly: + if payload: + payload = convert_bools_and_nones(payload) + if params: + params = convert_bools_and_nones(params) + + # and finally, make the request: + response = request( + method=SCHEMA["method"][0] if not override_method else override_method.upper(), + url=url, + headers=headers, + params=params, + data=payload if not use_json else None, + json=jsonbody if use_json else None, + auth=(auth_tuple if auth.auth_type == "basic" else None), + ) + + # check for errors: + if response.status_code in range(400, 599): + # print(f"Error: {response.text}") + # response.raise_for_status() + parsed = xml_parser(response.text) + # Common path is [SIMPLE_RETURN][RESPONSE][TEXT] + if "SIMPLE_RETURN" in parsed: + raise QualysAPIError(parsed["SIMPLE_RETURN"]["RESPONSE"]["TEXT"]) + elif "html" in parsed: + raise Exception( + f"Error: {parsed['html']['body']['h1']}: {parsed['html']['body']['p'][1]['#text']}" + ) + else: + raise Exception( + f"Error: {parsed['{http://www.w3.org/1999/xhtml}html']['{http://www.w3.org/1999/xhtml}body']['{http://www.w3.org/1999/xhtml}h1']}" + ) + + return response diff --git a/qualyspy/base/call_schema.py b/qualyspy/base/call_schema.py index db3cba6..fcbe451 100644 --- a/qualyspy/base/call_schema.py +++ b/qualyspy/base/call_schema.py @@ -1,418 +1,458 @@ -""" -call_schema.py - contains the CALL_SCHEMA lookup for the QualysAPI package. - -The CALL_SCHEMA dictionary contains the schema for each Qualys API call. -This schema is used to determine what parameters are required for each call and where/how -they should be sent to the API. -""" - -from frozendict import frozendict - -# frozen schema for all calls: -CALL_SCHEMA = frozendict( - { - "gav": { - "url_type": "gateway", - "count_assets": { - "endpoint": "/am/v1/assets/host/count", - "method": ["POST"], - "valid_params": ["filter", "lastSeenAssetId", "lastModifiedDate"], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "json", - "pagination": False, - "auth_type": "token", - }, - "get_all_assets": { - "endpoint": "/am/v1/assets/host/list", - "method": ["POST"], - "valid_params": [ - "excludeFields", - "includeFields", - "lastModifiedDate", - "lastSeenAssetId", - "pageSize", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "json", - "pagination": True, - "auth_type": "token", - }, - "get_asset": { - "endpoint": "/am/v1/asset/host/id", - "method": ["POST"], - "valid_params": ["excludeFields", "includeFields", "assetId"], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "json", - "pagination": False, - "auth_type": "token", - }, - "query_assets": { - "endpoint": "/am/v1/assets/host/filter/list", - "method": ["POST"], - "valid_params": [ - "filter", - "excludeFields", - "includeFields", - "lastModifiedDate", - "lastSeenAssetId", - "pageSize", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "json", - "pagination": True, - "auth_type": "token", - }, - }, - "vmdr": { - "url_type": "api", - "query_kb": { - "endpoint": "/api/2.0/fo/knowledge_base/vuln/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "code_modified_after", - "code_modified_before", - "echo_request", - "details", - "ids", - "id_min", - "id_max", - "is_patchable", - "last_modified_after", - "last_modified_before", - "last_modified_by_user_after", - "last_modified_by_user_before", - "last_modified_by_service_after", - "last_modified_by_service_before", - "published_after", - "published_before", - "discovery_method", - "discovery_auth_types", - "show_pci_reasons", - "show_supported_modules_info", - "show_disabled_flag", - "show_qid_change_log", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - "get_host_list": { - "endpoint": "/api/2.0/fo/asset/host/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "echo_request", - "show_asset_id", - "details", - "os_pattern", - "truncation_limit", - "ips", - "ipv6", - "ag_ids", - "ag_titles", - "ids", - "id_min", - "id_max", - "network_ids", - "compliance_enabled", - "no_vm_scan_since", - "no_compliance_scan_since", - "vm_scan_since", - "compliance_scan_since", - "vm_processed_before", - "vm_processed_after", - "vm_scan_date_before", - "vm_scan_date_after", - "vm_auth_scan_date_before", - "vm_auth_scan_date_after", - "scap_scan_since", - "no_scap_scan_since", - "use_tags", - "show_tags", - "tag_set_by", - "tag_include_selector", - "tag_exclude_selector", - "tag_set_include", - "tag_set_exclude", - "show_ars", - "ars_min", - "ars_max", - "show_ars_factors", - "show_trurisk", - "trurisk_min", - "trurisk_max", - "show_trurisk_factors", - "host_metadata", - "host_metadata_fields", - "show_cloud_tags", - "cloud_tag_fields", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": True, - "auth_type": "basic", - }, - "get_hld": { - "endpoint": "/api/2.0/fo/asset/host/vm/detection/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "echo_request", - "show_asset_id", - "show_results", - "include_vuln_type", - "arf_kernel_filter", - "arf_service_filter", - "arf_config_filter", - "active_kernels_only", - "output_format", - "suppress_duplicated_data_from_csv", - "truncation_limit", - "max_days_since_detection_updated", - "detection_updated_since", - "detection_updated_before", - "detection_processed_before", - "detection_processed_after", - "detection_last_tested_since", - "detection_last_tested_since_days", - "detection_last_tested_before", - "detection_last_tested_before_days", - "include_ignored", - "include_disabled", - "ids", - "id_min", - "id_max", - "ips", - "ipv6", - "ag_ids", - "ag_titles", - "network_ids", - "network_name", - "vm_scan_since", - "no_vm_scan_since", - "max_days_since_last_vm_scan", - "vm_processed_before", - "vm_processed_after", - "vm_scan_date_before", - "vm_scan_date_after", - "vm_auth_scan_date_before", - "vm_auth_scan_date_after", - "status", - "compliance_enabled", - "os_pattern", - "qids", - "severities", - "filter_superseded_qids", - "include_search_list_titles", - "exclude_search_list_titles", - "include_search_list_ids", - "exclude_search_list_ids", - "use_tags", - "tag_set_by", - "tag_include_selector", - "tag_exclude_selector", - "tag_set_include", - "tag_set_exclude", - "show_tags", - "show_qds", - "qds_min", - "qds_max", - "show_qds_factors", - "host_metadata", - "host_metadata_fields", - "show_cloud_tags", - "cloud_tag_fields", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": True, - "auth_type": "basic", - }, - "get_ip_list": { - "endpoint": "/api/2.0/fo/asset/ip/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "echo_request", - "ips", - "network_id", - "tracking_method", - "compliance_enabled", - "certview_enabled", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - "add_ips": { - "endpoint": "/api/2.0/fo/asset/ip/", - "method": ["POST"], - "valid_params": [], - "valid_POST_data": [ - "action", - "echo_request", - "ips", - "tracking_method", - "enable_vm", - "enable_pc", - "owner", - "ud1", - "ud2", - "ud3", - "comment", - "ag_title", - "enable_certview", - "enable_sca", - ], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - "update_ips": { - "endpoint": "/api/2.0/fo/asset/ip/", - "method": ["POST"], - "valid_params": [], - "valid_POST_data": [ - "action", - "echo_request", - "ips", - "network_id", - "tracking_method", - "host_dns", - "host_netbios", - "owner", - "ud1", - "ud2", - "ud3", - "comment", - ], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - "get_ag_list": { - "endpoint": "/api/2.0/fo/asset/group/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "echo_request", - "output_format", - "ids", - "id_min", - "id_max", - "truncation_limit", - "network_ids", - "unit_id", - "user_id", - "title", - "show_attributes", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": True, - "auth_type": "basic", - }, - "manage_ag": { # called by add_ag, update_ag, delete_ag - "endpoint": "/api/2.0/fo/asset/group/", - "method": ["POST"], - "valid_params": [], - "valid_POST_data": [ - "action", - "id", - "echo_request", - "title", - "comments", - "division", - "function", - "location", - "business_impact", - "ips", - "appliance_ids", - "default_appliance_id", - "domains", - "dns_names", - "netbios_names", - "cvss_enviro_cdp", - "cvss_enviro_td", - "cvss_enviro_cr", - "cvss_enviro_ir", - "cvss_enviro_ar", - "set_comments", - "set_division", - "set_function", - "set_location", - "set_business_impact", - "add_ips", - "remove_ips", - "set_ips", - "add_appliance_ids", - "remove_appliance_ids", - "set_appliance_ids", - "set_default_appliance_id", - "add_domains", - "remove_domains", - "set_domains", - "add_dns_names", - "remove_dns_names", - "set_dns_names", - "add_netbios_names", - "remove_netbios_names", - "set_netbios_names", - "set_title", - "set_cvss_enviro_cdp", - "set_cvss_enviro_td", - "set_cvss_enviro_cr", - "set_cvss_enviro_ir", - "set_cvss_enviro_ar", - ], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - "list_scans": { - "endpoint": "/api/2.0/fo/scan/", - "method": ["GET", "POST"], - "valid_params": [ - "action", - "echo_request", - "scan_ref", - "state", - "processed", - "type", - "target", - "user_login", - "launched_after_datetime", - "launched_before_datetime", - "scan_type", - "client_id", - "client_name", - "show_ags", - "show_op", - "show_status", - "ignore_target", - "show_last", - ], - "valid_POST_data": [], - "use_requests_json_data": False, - "return_type": "xml", - "pagination": False, - "auth_type": "basic", - }, - }, - } -) +""" +call_schema.py - contains the CALL_SCHEMA lookup for the QualysAPI package. + +The CALL_SCHEMA dictionary contains the schema for each Qualys API call. +This schema is used to determine what parameters are required for each call and where/how +they should be sent to the API. +""" + +from frozendict import frozendict + +# frozen schema for all calls: +CALL_SCHEMA = frozendict( + { + "gav": { + "url_type": "gateway", + "count_assets": { + "endpoint": "/am/v1/assets/host/count", + "method": ["POST"], + "valid_params": ["filter", "lastSeenAssetId", "lastModifiedDate"], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "json", + "pagination": False, + "auth_type": "token", + }, + "get_all_assets": { + "endpoint": "/am/v1/assets/host/list", + "method": ["POST"], + "valid_params": [ + "excludeFields", + "includeFields", + "lastModifiedDate", + "lastSeenAssetId", + "pageSize", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "json", + "pagination": True, + "auth_type": "token", + }, + "get_asset": { + "endpoint": "/am/v1/asset/host/id", + "method": ["POST"], + "valid_params": ["excludeFields", "includeFields", "assetId"], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "json", + "pagination": False, + "auth_type": "token", + }, + "query_assets": { + "endpoint": "/am/v1/assets/host/filter/list", + "method": ["POST"], + "valid_params": [ + "filter", + "excludeFields", + "includeFields", + "lastModifiedDate", + "lastSeenAssetId", + "pageSize", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "json", + "pagination": True, + "auth_type": "token", + }, + }, + "vmdr": { + "url_type": "api", + "query_kb": { + "endpoint": "/api/2.0/fo/knowledge_base/vuln/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "code_modified_after", + "code_modified_before", + "echo_request", + "details", + "ids", + "id_min", + "id_max", + "is_patchable", + "last_modified_after", + "last_modified_before", + "last_modified_by_user_after", + "last_modified_by_user_before", + "last_modified_by_service_after", + "last_modified_by_service_before", + "published_after", + "published_before", + "discovery_method", + "discovery_auth_types", + "show_pci_reasons", + "show_supported_modules_info", + "show_disabled_flag", + "show_qid_change_log", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "get_host_list": { + "endpoint": "/api/2.0/fo/asset/host/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "echo_request", + "show_asset_id", + "details", + "os_pattern", + "truncation_limit", + "ips", + "ipv6", + "ag_ids", + "ag_titles", + "ids", + "id_min", + "id_max", + "network_ids", + "compliance_enabled", + "no_vm_scan_since", + "no_compliance_scan_since", + "vm_scan_since", + "compliance_scan_since", + "vm_processed_before", + "vm_processed_after", + "vm_scan_date_before", + "vm_scan_date_after", + "vm_auth_scan_date_before", + "vm_auth_scan_date_after", + "scap_scan_since", + "no_scap_scan_since", + "use_tags", + "show_tags", + "tag_set_by", + "tag_include_selector", + "tag_exclude_selector", + "tag_set_include", + "tag_set_exclude", + "show_ars", + "ars_min", + "ars_max", + "show_ars_factors", + "show_trurisk", + "trurisk_min", + "trurisk_max", + "show_trurisk_factors", + "host_metadata", + "host_metadata_fields", + "show_cloud_tags", + "cloud_tag_fields", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": True, + "auth_type": "basic", + }, + "get_hld": { + "endpoint": "/api/2.0/fo/asset/host/vm/detection/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "echo_request", + "show_asset_id", + "show_results", + "include_vuln_type", + "arf_kernel_filter", + "arf_service_filter", + "arf_config_filter", + "active_kernels_only", + "output_format", + "suppress_duplicated_data_from_csv", + "truncation_limit", + "max_days_since_detection_updated", + "detection_updated_since", + "detection_updated_before", + "detection_processed_before", + "detection_processed_after", + "detection_last_tested_since", + "detection_last_tested_since_days", + "detection_last_tested_before", + "detection_last_tested_before_days", + "include_ignored", + "include_disabled", + "ids", + "id_min", + "id_max", + "ips", + "ipv6", + "ag_ids", + "ag_titles", + "network_ids", + "network_name", + "vm_scan_since", + "no_vm_scan_since", + "max_days_since_last_vm_scan", + "vm_processed_before", + "vm_processed_after", + "vm_scan_date_before", + "vm_scan_date_after", + "vm_auth_scan_date_before", + "vm_auth_scan_date_after", + "status", + "compliance_enabled", + "os_pattern", + "qids", + "severities", + "filter_superseded_qids", + "include_search_list_titles", + "exclude_search_list_titles", + "include_search_list_ids", + "exclude_search_list_ids", + "use_tags", + "tag_set_by", + "tag_include_selector", + "tag_exclude_selector", + "tag_set_include", + "tag_set_exclude", + "show_tags", + "show_qds", + "qds_min", + "qds_max", + "show_qds_factors", + "host_metadata", + "host_metadata_fields", + "show_cloud_tags", + "cloud_tag_fields", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": True, + "auth_type": "basic", + }, + "get_ip_list": { + "endpoint": "/api/2.0/fo/asset/ip/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "echo_request", + "ips", + "network_id", + "tracking_method", + "compliance_enabled", + "certview_enabled", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "add_ips": { + "endpoint": "/api/2.0/fo/asset/ip/", + "method": ["POST"], + "valid_params": [], + "valid_POST_data": [ + "action", + "echo_request", + "ips", + "tracking_method", + "enable_vm", + "enable_pc", + "owner", + "ud1", + "ud2", + "ud3", + "comment", + "ag_title", + "enable_certview", + "enable_sca", + ], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "update_ips": { + "endpoint": "/api/2.0/fo/asset/ip/", + "method": ["POST"], + "valid_params": [], + "valid_POST_data": [ + "action", + "echo_request", + "ips", + "network_id", + "tracking_method", + "host_dns", + "host_netbios", + "owner", + "ud1", + "ud2", + "ud3", + "comment", + ], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "get_ag_list": { + "endpoint": "/api/2.0/fo/asset/group/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "echo_request", + "output_format", + "ids", + "id_min", + "id_max", + "truncation_limit", + "network_ids", + "unit_id", + "user_id", + "title", + "show_attributes", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": True, + "auth_type": "basic", + }, + "manage_ag": { # called by add_ag, update_ag, delete_ag + "endpoint": "/api/2.0/fo/asset/group/", + "method": ["POST"], + "valid_params": [], + "valid_POST_data": [ + "action", + "id", + "echo_request", + "title", + "comments", + "division", + "function", + "location", + "business_impact", + "ips", + "appliance_ids", + "default_appliance_id", + "domains", + "dns_names", + "netbios_names", + "cvss_enviro_cdp", + "cvss_enviro_td", + "cvss_enviro_cr", + "cvss_enviro_ir", + "cvss_enviro_ar", + "set_comments", + "set_division", + "set_function", + "set_location", + "set_business_impact", + "add_ips", + "remove_ips", + "set_ips", + "add_appliance_ids", + "remove_appliance_ids", + "set_appliance_ids", + "set_default_appliance_id", + "add_domains", + "remove_domains", + "set_domains", + "add_dns_names", + "remove_dns_names", + "set_dns_names", + "add_netbios_names", + "remove_netbios_names", + "set_netbios_names", + "set_title", + "set_cvss_enviro_cdp", + "set_cvss_enviro_td", + "set_cvss_enviro_cr", + "set_cvss_enviro_ir", + "set_cvss_enviro_ar", + ], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "list_scans": { + "endpoint": "/api/2.0/fo/scan/", + "method": ["GET", "POST"], + "valid_params": [ + "action", + "echo_request", + "scan_ref", + "state", + "processed", + "type", + "target", + "user_login", + "launched_after_datetime", + "launched_before_datetime", + "scan_type", + "client_id", + "client_name", + "show_ags", + "show_op", + "show_status", + "ignore_target", + "show_last", + ], + "valid_POST_data": [], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + "launch_scan": { + "endpoint": "/api/2.0/fo/scan/", + "method": ["POST"], + "valid_params": [], + "valid_POST_data": [ + "action", + "echo_request", + "runtime_http_header", + "scan_title", + "option_id", + "option_title", + "iscanner_id", + "iscanner_name", + "ec2_instance_ids", + "ip", + "asset_group_ids", + "asset_groups", + "exclude_ip_per_scan", + "default_scanner", + "scanners_in_ag", + "scanners_in_network", # set to 1 to use all scanners + "target_from" # must be tags + "use_ip_nt_range_tags_include", + "use_ip_nt_range_tags_exclude", + "use_ip_nt_range_tags", + "tag_include_selector", + "tag_exclude_selector", + "tag_set_by", + "tag_set_exclude", + "tag_set_include", + "ip_network_id", # must be enabled in subscription + "client_id", # only for consultant subscriptions + "client_name", # only for consultant subscriptions + "fqdn", + ], + "use_requests_json_data": False, + "return_type": "xml", + "pagination": False, + "auth_type": "basic", + }, + }, + } +) diff --git a/qualyspy/base/convert_bools_and_nones.py b/qualyspy/base/convert_bools_and_nones.py index 3853b97..36f566b 100644 --- a/qualyspy/base/convert_bools_and_nones.py +++ b/qualyspy/base/convert_bools_and_nones.py @@ -1,32 +1,32 @@ -""" -convert_bools_and_nones.py - Contains functions to convert bools and Nones to 1/0 and 'None' respectively. - -Used with the various kwargs an API endpoint accepts to get them into a requests friendly format. -""" - - -def convert_bools_and_nones(kwargs: dict) -> dict: - """ - Converts bools to 1/0 and Nones to 'None' in a dictionary. - - Args: - kwargs (dict): Dictionary of keyword arguments. - - Returns: - dict: Dictionary with bools converted to 1/0 and Nones converted to 'None'. - """ - # If any kwarg is a bool, convert it to 1 or 0 - for key in kwargs: - if isinstance(kwargs[key], bool): - kwargs[key] = 1 if kwargs[key] else 0 - - # If any kwarg is None, set it to 'None' - for key in kwargs: - if kwargs[key] is None: - kwargs[key] = "None" - - # if host_metadata is specified and set to a non-"all" or non-NoneType, lower() it: - if key == "host_metadata" and kwargs[key] not in ["all", None]: - kwargs[key] = kwargs[key].lower() - - return kwargs +""" +convert_bools_and_nones.py - Contains functions to convert bools and Nones to 1/0 and 'None' respectively. + +Used with the various kwargs an API endpoint accepts to get them into a requests friendly format. +""" + + +def convert_bools_and_nones(kwargs: dict) -> dict: + """ + Converts bools to 1/0 and Nones to 'None' in a dictionary. + + Params: + kwargs (dict): Dictionary of keyword arguments. + + Returns: + dict: Dictionary with bools converted to 1/0 and Nones converted to 'None'. + """ + # If any kwarg is a bool, convert it to 1 or 0 + for key in kwargs: + if isinstance(kwargs[key], bool): + kwargs[key] = 1 if kwargs[key] else 0 + + # If any kwarg is None, set it to 'None' + for key in kwargs: + if kwargs[key] is None: + kwargs[key] = "None" + + # if host_metadata is specified and set to a non-"all" or non-NoneType, lower() it: + if key == "host_metadata" and kwargs[key] not in ["all", None]: + kwargs[key] = kwargs[key].lower() + + return kwargs diff --git a/qualyspy/base/xml_parser.py b/qualyspy/base/xml_parser.py index 0070393..2e86c6e 100644 --- a/qualyspy/base/xml_parser.py +++ b/qualyspy/base/xml_parser.py @@ -1,50 +1,50 @@ -""" -xml_parser.py - contains the xml_parser function that parses an XML string into a dictionary. -""" - -from lxml import etree - - -def xml_parser(xml_string, attr_prefix="@", cdata_key="#text"): - """ - Turn an xml string into a dictionary. - - Args: - xml_string (str): The xml string to parse. - attr_prefix (str): The prefix to add to attributes. - cdata_key (str): The key to use for cdata. - - Returns: - dict: The parsed xml as a dictionary. - """ - # check if the user passed in a string or bytes. if string, encode it to utf-8 - if isinstance(xml_string, str): - xml_string = xml_string.encode("utf-8") - - def parse_element(element): - parsed_dict = {} - # Parse attributes - for key, value in element.attrib.items(): - parsed_dict[attr_prefix + key] = value - # Parse child elements - for child in element: - if isinstance(child, etree._Comment): - continue # Skip comments - child_dict = parse_element(child) - if child.tag in parsed_dict: - if not isinstance(parsed_dict[child.tag], list): - parsed_dict[child.tag] = [parsed_dict[child.tag]] - parsed_dict[child.tag].append(child_dict) - else: - parsed_dict[child.tag] = child_dict - # Parse text content - text = (element.text or "").strip() - if text: - if parsed_dict: - parsed_dict[cdata_key] = text - else: - parsed_dict = text - return parsed_dict - - root = etree.fromstring(xml_string) - return {root.tag: parse_element(root)} +""" +xml_parser.py - contains the xml_parser function that parses an XML string into a dictionary. +""" + +from lxml import etree + + +def xml_parser(xml_string, attr_prefix="@", cdata_key="#text"): + """ + Turn an xml string into a dictionary. + + Params: + xml_string (str): The xml string to parse. + attr_prefix (str): The prefix to add to attributes. + cdata_key (str): The key to use for cdata. + + Returns: + dict: The parsed xml as a dictionary. + """ + # check if the user passed in a string or bytes. if string, encode it to utf-8 + if isinstance(xml_string, str): + xml_string = xml_string.encode("utf-8") + + def parse_element(element): + parsed_dict = {} + # Parse attributes + for key, value in element.attrib.items(): + parsed_dict[attr_prefix + key] = value + # Parse child elements + for child in element: + if isinstance(child, etree._Comment): + continue # Skip comments + child_dict = parse_element(child) + if child.tag in parsed_dict: + if not isinstance(parsed_dict[child.tag], list): + parsed_dict[child.tag] = [parsed_dict[child.tag]] + parsed_dict[child.tag].append(child_dict) + else: + parsed_dict[child.tag] = child_dict + # Parse text content + text = (element.text or "").strip() + if text: + if parsed_dict: + parsed_dict[cdata_key] = text + else: + parsed_dict = text + return parsed_dict + + root = etree.fromstring(xml_string) + return {root.tag: parse_element(root)} diff --git a/qualyspy/exceptions/Exceptions.py b/qualyspy/exceptions/Exceptions.py index 709617a..f5b8c1c 100644 --- a/qualyspy/exceptions/Exceptions.py +++ b/qualyspy/exceptions/Exceptions.py @@ -1,73 +1,73 @@ -""" -Exceptions.py - Custom Exceptions for package. -""" - - -class AuthenticationError(Exception): - """ - Basic exception class for QualysAPI when dealing with authentication. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class InvalidCredentialsError(AuthenticationError): - """ - Exception for when credentials are invalid. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class InvalidTokenError(AuthenticationError): - """ - Exception for when token is invalid. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class InvalidAuthTypeError(AuthenticationError): - """ - Exception for when auth type is invalid. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class AuthTypeMismatchError(AuthenticationError): - """ - Exception for when auth type is invalid. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class InvalidEndpointError(Exception): - """ - Exception for when endpoint is invalid. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) - - -class QualysAPIError(Exception): - """ - Basic exception class for QualysAPI. - """ - - def __init__(self, message: str): - self.message = message - super().__init__(message) +""" +Exceptions.py - Custom Exceptions for package. +""" + + +class AuthenticationError(Exception): + """ + Basic exception class for QualysAPI when dealing with authentication. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class InvalidCredentialsError(AuthenticationError): + """ + Exception for when credentials are invalid. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class InvalidTokenError(AuthenticationError): + """ + Exception for when token is invalid. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class InvalidAuthTypeError(AuthenticationError): + """ + Exception for when auth type is invalid. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class AuthTypeMismatchError(AuthenticationError): + """ + Exception for when auth type is invalid. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class InvalidEndpointError(Exception): + """ + Exception for when endpoint is invalid. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class QualysAPIError(Exception): + """ + Basic exception class for QualysAPI. + """ + + def __init__(self, message: str): + self.message = message + super().__init__(message) diff --git a/qualyspy/exceptions/__init__.py b/qualyspy/exceptions/__init__.py index 4589575..60225bd 100644 --- a/qualyspy/exceptions/__init__.py +++ b/qualyspy/exceptions/__init__.py @@ -1,5 +1,5 @@ -""" -Exceptions for the QualysAPI module. -""" - -from .Exceptions import * +""" +Exceptions for the QualysAPI module. +""" + +from .Exceptions import * diff --git a/qualyspy/gav/__init__.py b/qualyspy/gav/__init__.py index 55012bf..f587460 100644 --- a/qualyspy/gav/__init__.py +++ b/qualyspy/gav/__init__.py @@ -1,13 +1,13 @@ -""" -Global AssetView API (GAV) module - -This module contains ways to interact with the Qualys Global AssetView API (GAV). Valid endpoints are defined in the CALL_SCHEMA dictionary. - -GAV QQL Syntax help: https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm -""" - -from .count_assets import count_assets -from .get_all_assets import get_all_assets -from .get_asset import get_asset -from .query_assets import query_assets -from .uber import GAVUber +""" +Global AssetView API (GAV) module + +This module contains ways to interact with the Qualys Global AssetView API (GAV). Valid endpoints are defined in the CALL_SCHEMA dictionary. + +GAV QQL Syntax help: https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm +""" + +from .count_assets import count_assets +from .get_all_assets import get_all_assets +from .get_asset import get_asset +from .query_assets import query_assets +from .uber import GAVUber diff --git a/qualyspy/gav/count_assets.py b/qualyspy/gav/count_assets.py index 4cf5102..32c50c1 100644 --- a/qualyspy/gav/count_assets.py +++ b/qualyspy/gav/count_assets.py @@ -1,33 +1,33 @@ -""" -count_assets.py - contains the count_assets function for the Global AssetView API (GAV) module. - -for a full list of Qualys QQL filters, see: https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm -""" - -from ..base.call_api import call_api -from ..auth.token import TokenAuth -from ..exceptions.Exceptions import * - - -def count_assets(auth: TokenAuth, **kwargs): - """ - Count assets in the Global AssetView API based on a QQL filter. - - Args: - auth (TokenAuth): The authentication object. - API params (kwargs): - filter (str): The Qualys QQL filter to use. - lastSeenAssetId (int): The last seen asset ID. - lastModifiedDate (str): The last modified date. - - Returns: - dict: The response from the API. - """ - # despite the fact that this is a POST request, we still need to send stuff as a parameter - # because Qualys is Qualys. - - # make the request: - response = call_api(auth=auth, module="gav", endpoint="count_assets", params=kwargs) - - # parse the response: - return response.json() +""" +count_assets.py - contains the count_assets function for the Global AssetView API (GAV) module. + +for a full list of Qualys QQL filters, see: https://docs.qualys.com/en/gav/2.18.0.0/search/how_to_search.htm +""" + +from ..base.call_api import call_api +from ..auth.token import TokenAuth +from ..exceptions.Exceptions import * + + +def count_assets(auth: TokenAuth, **kwargs): + """ + Count assets in the Global AssetView API based on a QQL filter. + + Params: + auth (TokenAuth): The authentication object. + API params (kwargs): + filter (str): The Qualys QQL filter to use. + lastSeenAssetId (int): The last seen asset ID. + lastModifiedDate (str): The last modified date. + + Returns: + dict: The response from the API. + """ + # despite the fact that this is a POST request, we still need to send stuff as a parameter + # because Qualys is Qualys. + + # make the request: + response = call_api(auth=auth, module="gav", endpoint="count_assets", params=kwargs) + + # parse the response: + return response.json() diff --git a/qualyspy/gav/get_all_assets.py b/qualyspy/gav/get_all_assets.py index 11a2125..f744d05 100644 --- a/qualyspy/gav/get_all_assets.py +++ b/qualyspy/gav/get_all_assets.py @@ -1,63 +1,63 @@ -""" -get_all_assets.py - contains the get_all_assets function for the Global AssetView API (GAV) module. -""" - -from typing import Union - -from ..base.call_api import call_api -from ..auth.token import TokenAuth -from ..exceptions.Exceptions import * -from .hosts import Host - - -def get_all_assets( - auth: TokenAuth, page_count: Union[int, "all"] = "all", **kwargs -) -> list: - """ - Get all assets in the Global AssetView API. - - Args: - auth (TokenAuth): The authentication object. - page_count (Union[int, "all"]): The number of pages to get. If "all", get all pages. Defaults to "all". - API params (kwargs): - excludeFields (str): The fields to exclude. - includeFields (str): The fields to include. - lastSeenAssetId (int): The last seen asset ID. Used for automatic pagination. - lastModifiedDate (str): The last modified date. - pageSize (int): The number of assets to get per page. - - Returns: - list[Hosts]: The response from the API as a list of Hosts objects. - """ - - responses = [] # list to hold all the responses - pulled = 0 - - while True: - # make the request: - response = call_api( - auth=auth, module="gav", endpoint="get_all_assets", params=kwargs - ) - j = response.json() - for record in j["assetListData"]["asset"]: - responses.append(Host(**record)) - ( - print(f"Page {pulled+1} of {page_count} complete.") - if page_count != "all" - else print(f"Page {pulled+1} complete.") - ) - pulled += 1 - - if not j["hasMore"]: - print("No more records.") - break - - if page_count != "all" and pulled >= page_count: - print("Page count reached.") - break - - else: - kwargs["lastSeenAssetId"] = j["lastSeenAssetId"] - - print("All pages complete.") - return responses +""" +get_all_assets.py - contains the get_all_assets function for the Global AssetView API (GAV) module. +""" + +from typing import Union + +from ..base.call_api import call_api +from ..auth.token import TokenAuth +from ..exceptions.Exceptions import * +from .hosts import Host + + +def get_all_assets( + auth: TokenAuth, page_count: Union[int, "all"] = "all", **kwargs +) -> list: + """ + Get all assets in the Global AssetView API. + + Params: + auth (TokenAuth): The authentication object. + page_count (Union[int, "all"]): The number of pages to get. If "all", get all pages. Defaults to "all". + API params (kwargs): + excludeFields (str): The fields to exclude. + includeFields (str): The fields to include. + lastSeenAssetId (int): The last seen asset ID. Used for automatic pagination. + lastModifiedDate (str): The last modified date. + pageSize (int): The number of assets to get per page. + + Returns: + list[Hosts]: The response from the API as a list of Hosts objects. + """ + + responses = [] # list to hold all the responses + pulled = 0 + + while True: + # make the request: + response = call_api( + auth=auth, module="gav", endpoint="get_all_assets", params=kwargs + ) + j = response.json() + for record in j["assetListData"]["asset"]: + responses.append(Host(**record)) + ( + print(f"Page {pulled+1} of {page_count} complete.") + if page_count != "all" + else print(f"Page {pulled+1} complete.") + ) + pulled += 1 + + if not j["hasMore"]: + print("No more records.") + break + + if page_count != "all" and pulled >= page_count: + print("Page count reached.") + break + + else: + kwargs["lastSeenAssetId"] = j["lastSeenAssetId"] + + print("All pages complete.") + return responses diff --git a/qualyspy/gav/get_asset.py b/qualyspy/gav/get_asset.py index bb67c04..8569b2c 100644 --- a/qualyspy/gav/get_asset.py +++ b/qualyspy/gav/get_asset.py @@ -1,38 +1,38 @@ -""" -get_asset.py - get a specific asset from the Global AssetView API via its asset ID. -""" - -from ..base.call_api import call_api -from ..auth.token import TokenAuth -from ..exceptions.Exceptions import * -from .hosts import Host - - -def get_asset(auth: TokenAuth, **kwargs): - """ - Get a specific host from the Global AssetView API. - - Args: - auth (TokenAuth): The authentication object. - API params (kwargs): - assetId (int): The asset ID to get. - lastSeenAssetId (int): The last seen asset ID. - lastModifiedDate (str): The last modified date. - - Returns: - dict: The response from the API. - """ - # despite the fact that this is a POST request, we still need to send stuff as a parameter - # because Qualys is Qualys. - - # make the request: - response = call_api(auth=auth, module="gav", endpoint="get_asset", params=kwargs) - - # check for 204 response: - if response.status_code == 204: - raise ValueError(f"No asset found with ID {kwargs['assetId']}.") - - # parse the response: - j = response.json() - - return Host(**j["assetListData"]["asset"][0]) +""" +get_asset.py - get a specific asset from the Global AssetView API via its asset ID. +""" + +from ..base.call_api import call_api +from ..auth.token import TokenAuth +from ..exceptions.Exceptions import * +from .hosts import Host + + +def get_asset(auth: TokenAuth, **kwargs): + """ + Get a specific host from the Global AssetView API. + + Params: + auth (TokenAuth): The authentication object. + API params (kwargs): + assetId (int): The asset ID to get. + lastSeenAssetId (int): The last seen asset ID. + lastModifiedDate (str): The last modified date. + + Returns: + dict: The response from the API. + """ + # despite the fact that this is a POST request, we still need to send stuff as a parameter + # because Qualys is Qualys. + + # make the request: + response = call_api(auth=auth, module="gav", endpoint="get_asset", params=kwargs) + + # check for 204 response: + if response.status_code == 204: + raise ValueError(f"No asset found with ID {kwargs['assetId']}.") + + # parse the response: + j = response.json() + + return Host(**j["assetListData"]["asset"][0]) diff --git a/qualyspy/gav/hosts.py b/qualyspy/gav/hosts.py index 8ec0b81..395e51e 100644 --- a/qualyspy/gav/hosts.py +++ b/qualyspy/gav/hosts.py @@ -1,144 +1,144 @@ -""" -hosts.py - contains the dataclass for a Qualys GAV host record. -""" - -from dataclasses import dataclass -from typing import List, Optional - - -@dataclass(frozen=True) -class Host: - """ - Host - represents a Qualys GAV host record. - - due to the fact that some APIs have excludeFields and IncludeFields parameters, - virtually all fields are optional other than assetId. - """ - - assetId: int = None - assetUUID: Optional[str] = None - hostId: Optional[int] = None - lastModifiedDate: Optional[str] = None - agentId: Optional[str] = None - createdDate: Optional[str] = None - sensorLastUpdatedDate: Optional[str] = None - assetType: Optional[str] = None - address: Optional[str] = None - dnsName: Optional[str] = None - assetName: Optional[str] = None - netbiosName: Optional[str] = None - timeZone: Optional[str] = None - biosDescription: Optional[str] = None - lastBoot: Optional[str] = None - totalMemory: Optional[int] = None - cpuCount: Optional[int] = None - lastLoggedOnUser: Optional[str] = None - domainRole: Optional[str] = None - hwUUID: Optional[str] = None - biosSerialNumber: Optional[str] = None - biosAssetTag: Optional[str] = None - isContainerHost: Optional[bool] = None - operatingSystem: Optional[str] = None - hardwareVendor: Optional[str] = None - hardware: Optional[dict] = None - userAccountListData: Optional[dict] = None - openPortListData: Optional[dict] = None - volumeListData: Optional[dict] = None - networkInterfaceListData: Optional[dict] = None - softwareListData: Optional[dict] = None - softwareComponent: Optional[str] = None - provider: Optional[str] = None - cloudProvider: Optional[str] = None - agent: Optional[dict] = None - sensor: Optional[dict] = None - container: Optional[dict] = None - inventory: Optional[dict] = None - activity: Optional[dict] = None - tagList: Optional[List[str]] = None - serviceList: Optional[List[str]] = None - lastLocation: Optional[dict] = None - criticality: Optional[int] = None - businessInformation: Optional[dict] = None - assignedLocation: Optional[dict] = None - businessAppListData: Optional[dict] = None - riskScore: Optional[int] = None - passiveSensor: Optional[dict] = None - domain: Optional[str] = None - subdomain: Optional[str] = None - missingSoftware: Optional[list] = None - whois: Optional[dict] = None - organizationName: Optional[str] = None - isp: Optional[str] = None - asn: Optional[str] = None - easmTags: Optional[List[str]] = None - hostingCategory1: Optional[str] = None - customAttributes: Optional[dict] = None - processor: Optional[dict] = None - - def is_cloud_host(self) -> bool: - """ - Returns True if the host is a cloud host, False otherwise. - """ - return self.cloudProvider is not None - - def is_container_host(self) -> bool: - """ - Returns True if the host is a container host, False otherwise. - """ - return self.container is not None - - def has_agent(self) -> bool: - """ - Returns True if the host has an agent, False otherwise. - """ - return self.agentId is not None - - def to_dict(self) -> dict: - """ - Returns a dictionary representation of the host object. - """ - return self.__dict__ - - def keys(self) -> list: - """ - Returns a list of keys for the host object. - """ - return [key for key in self.__dict__.keys()] - - def values(self) -> list: - """ - Returns a list of values for the host object. - """ - return [value for value in self.__dict__.values()] - - def valid_values(self) -> list: - """ - Return a list of keys that have values. - """ - return [key for key, value in self.__dict__.items() if value is not None] - - def __iter__(self): - """ - Allows for iteration over the host object. - """ - for key, value in self.__dict__.items(): - yield key, value - - def __str__(self) -> str: - """ - String representation of the host object. - """ - return f"AssetID({self.assetId}))" - - def __int__(self) -> int: - """ - Integer representation of the host object. - """ - return self.assetId - - def __repr__(self) -> str: - """ - Breaking the unwritten rule of having repr be something - that can create the object for terminal space's sake. - """ - return f"AssetID({self.assetId})" +""" +hosts.py - contains the dataclass for a Qualys GAV host record. +""" + +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass(frozen=True) +class Host: + """ + Host - represents a Qualys GAV host record. + + due to the fact that some APIs have excludeFields and IncludeFields parameters, + virtually all fields are optional other than assetId. + """ + + assetId: int = None + assetUUID: Optional[str] = None + hostId: Optional[int] = None + lastModifiedDate: Optional[str] = None + agentId: Optional[str] = None + createdDate: Optional[str] = None + sensorLastUpdatedDate: Optional[str] = None + assetType: Optional[str] = None + address: Optional[str] = None + dnsName: Optional[str] = None + assetName: Optional[str] = None + netbiosName: Optional[str] = None + timeZone: Optional[str] = None + biosDescription: Optional[str] = None + lastBoot: Optional[str] = None + totalMemory: Optional[int] = None + cpuCount: Optional[int] = None + lastLoggedOnUser: Optional[str] = None + domainRole: Optional[str] = None + hwUUID: Optional[str] = None + biosSerialNumber: Optional[str] = None + biosAssetTag: Optional[str] = None + isContainerHost: Optional[bool] = None + operatingSystem: Optional[str] = None + hardwareVendor: Optional[str] = None + hardware: Optional[dict] = None + userAccountListData: Optional[dict] = None + openPortListData: Optional[dict] = None + volumeListData: Optional[dict] = None + networkInterfaceListData: Optional[dict] = None + softwareListData: Optional[dict] = None + softwareComponent: Optional[str] = None + provider: Optional[str] = None + cloudProvider: Optional[str] = None + agent: Optional[dict] = None + sensor: Optional[dict] = None + container: Optional[dict] = None + inventory: Optional[dict] = None + activity: Optional[dict] = None + tagList: Optional[List[str]] = None + serviceList: Optional[List[str]] = None + lastLocation: Optional[dict] = None + criticality: Optional[int] = None + businessInformation: Optional[dict] = None + assignedLocation: Optional[dict] = None + businessAppListData: Optional[dict] = None + riskScore: Optional[int] = None + passiveSensor: Optional[dict] = None + domain: Optional[str] = None + subdomain: Optional[str] = None + missingSoftware: Optional[list] = None + whois: Optional[dict] = None + organizationName: Optional[str] = None + isp: Optional[str] = None + asn: Optional[str] = None + easmTags: Optional[List[str]] = None + hostingCategory1: Optional[str] = None + customAttributes: Optional[dict] = None + processor: Optional[dict] = None + + def is_cloud_host(self) -> bool: + """ + Returns True if the host is a cloud host, False otherwise. + """ + return self.cloudProvider is not None + + def is_container_host(self) -> bool: + """ + Returns True if the host is a container host, False otherwise. + """ + return self.container is not None + + def has_agent(self) -> bool: + """ + Returns True if the host has an agent, False otherwise. + """ + return self.agentId is not None + + def to_dict(self) -> dict: + """ + Returns a dictionary representation of the host object. + """ + return self.__dict__ + + def keys(self) -> list: + """ + Returns a list of keys for the host object. + """ + return [key for key in self.__dict__.keys()] + + def values(self) -> list: + """ + Returns a list of values for the host object. + """ + return [value for value in self.__dict__.values()] + + def valid_values(self) -> list: + """ + Return a list of keys that have values. + """ + return [key for key, value in self.__dict__.items() if value is not None] + + def __iter__(self): + """ + Allows for iteration over the host object. + """ + for key, value in self.__dict__.items(): + yield key, value + + def __str__(self) -> str: + """ + String representation of the host object. + """ + return f"AssetID({self.assetId}))" + + def __int__(self) -> int: + """ + Integer representation of the host object. + """ + return self.assetId + + def __repr__(self) -> str: + """ + Breaking the unwritten rule of having repr be something + that can create the object for terminal space's sake. + """ + return f"AssetID({self.assetId})" diff --git a/qualyspy/gav/query_assets.py b/qualyspy/gav/query_assets.py index 950db61..0ab31ff 100644 --- a/qualyspy/gav/query_assets.py +++ b/qualyspy/gav/query_assets.py @@ -1,67 +1,67 @@ -""" -query_assets.py - contains the query_assets function for the Global AssetView API (GAV) module. - -Gets all assets that satisfy a Qualys Query Language (QQL) filter. -""" - -from typing import Union - -from ..base.call_api import call_api -from ..auth.token import TokenAuth -from ..exceptions.Exceptions import * -from .hosts import Host - - -def query_assets(auth: TokenAuth, page_count: Union["all", int] = "all", **kwargs): - """ - Queries GAV inventory for assets that satisfy a Qualys Query Language (QQL) filter. - - Args: - auth (TokenAuth): The authentication object. - page_count (int): The number of pages to get. Defaults to 'all'. - - API params (kwargs): - filter (str): The Qualys QQL filter to use. - excludeFields (str): The fields to exclude. - includeFields (str): The fields to include. - lastSeenAssetId (int): The last seen asset ID. Used for automatic pagination. - lastModifiedDate (str): The last modified date. - pageSize (int): The number of assets to get per page. - """ - - responses = [] # list to hold all the responses - pulled = 0 - - while True: - # make the request: - response = call_api( - auth=auth, module="gav", endpoint="query_assets", params=kwargs - ) - # if there is no response, break the loop - if response.text == "": - print("No Results returned.") - break - - j = response.json() - for record in j["assetListData"]["asset"]: - responses.append(Host(**record)) - ( - print(f"Page {pulled+1} of {page_count}complete.") - if page_count != "all" - else print(f"Page {pulled+1} complete.") - ) - pulled += 1 - - if not j["hasMore"]: - print("No more records.") - break - - if page_count != "all" and pulled >= page_count: - print("Page count reached.") - break - - else: - kwargs["lastSeenAssetId"] = j["lastSeenAssetId"] - - print("All pages complete.") - return responses +""" +query_assets.py - contains the query_assets function for the Global AssetView API (GAV) module. + +Gets all assets that satisfy a Qualys Query Language (QQL) filter. +""" + +from typing import Union + +from ..base.call_api import call_api +from ..auth.token import TokenAuth +from ..exceptions.Exceptions import * +from .hosts import Host + + +def query_assets(auth: TokenAuth, page_count: Union["all", int] = "all", **kwargs): + """ + Queries GAV inventory for assets that satisfy a Qualys Query Language (QQL) filter. + + Params: + auth (TokenAuth): The authentication object. + page_count (int): The number of pages to get. Defaults to 'all'. + + API params (kwargs): + filter (str): The Qualys QQL filter to use. + excludeFields (str): The fields to exclude. + includeFields (str): The fields to include. + lastSeenAssetId (int): The last seen asset ID. Used for automatic pagination. + lastModifiedDate (str): The last modified date. + pageSize (int): The number of assets to get per page. + """ + + responses = [] # list to hold all the responses + pulled = 0 + + while True: + # make the request: + response = call_api( + auth=auth, module="gav", endpoint="query_assets", params=kwargs + ) + # if there is no response, break the loop + if response.text == "": + print("No Results returned.") + break + + j = response.json() + for record in j["assetListData"]["asset"]: + responses.append(Host(**record)) + ( + print(f"Page {pulled+1} of {page_count}complete.") + if page_count != "all" + else print(f"Page {pulled+1} complete.") + ) + pulled += 1 + + if not j["hasMore"]: + print("No more records.") + break + + if page_count != "all" and pulled >= page_count: + print("Page count reached.") + break + + else: + kwargs["lastSeenAssetId"] = j["lastSeenAssetId"] + + print("All pages complete.") + return responses diff --git a/qualyspy/gav/uber.py b/qualyspy/gav/uber.py index 57c3fd9..f36af00 100644 --- a/qualyspy/gav/uber.py +++ b/qualyspy/gav/uber.py @@ -1,71 +1,71 @@ -""" -uber.py - an Uber class that allows for easy access to all GAV API endpoints, -plus some extra functionality such as exporting API results to files/SQL databases. -""" - -from typing import Union, Literal - -from . import * -from ..base.call_schema import CALL_SCHEMA -from ..auth import * -from ..exceptions.Exceptions import * - - -class GAVUber: - """ - The Uber class is a class that allows for easy access to all GAV API endpoints. - """ - - def __init__(self, auth: Union[TokenAuth, BasicAuth]): - """ - Initialize the Uber class. - """ - self.NON_ENDPOINTS = ["url_type"] # may expand over time.. your move, Qualys. - self.auth = auth - self.valid_endpoints = [ - i for i in CALL_SCHEMA["gav"].keys() if i not in self.NON_ENDPOINTS - ] # grab actual endpoints - - # check if the auth type is valid: - if self.auth.auth_type not in ["token", "basic"]: - raise InvalidAuthTypeError( - f"Invalid auth type: {self.auth.auth_type}. Acceptable auth types are: 'token' or 'basic'." - ) - - def __str__(self) -> str: - return f"Uber class for GAV API with auth type: {self.auth.auth_type}." - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - pass - - def get( - self, - endpoint: Literal[ - "count_assets", "get_all_assets", "get_asset", "query_assets" - ], - **kwargs, - ): - """ - Call the appropriate endpoint based on passed endpoint. - - Args: - endpoint (str): The endpoint to call. Must be one of the keys in CALL_SCHEMA["gav"]. - **kwargs: The keyword arguments to pass to the endpoint. - """ - - match endpoint: - case "count_assets": - return count_assets(self.auth, **kwargs) - case "get_all_assets": - return get_all_assets(self.auth, **kwargs) - case "get_asset": - return get_asset(self.auth, **kwargs) - case "query_assets": - return query_assets(self.auth, **kwargs) - case _: - raise InvalidEndpointError( - f"Invalid endpoint: {endpoint}. Acceptable endpoints are: {self.valid_endpoints}." - ) +""" +uber.py - an Uber class that allows for easy access to all GAV API endpoints, +plus some extra functionality such as exporting API results to files/SQL databases. +""" + +from typing import Union, Literal + +from . import * +from ..base.call_schema import CALL_SCHEMA +from ..auth import * +from ..exceptions.Exceptions import * + + +class GAVUber: + """ + The Uber class is a class that allows for easy access to all GAV API endpoints. + """ + + def __init__(self, auth: Union[TokenAuth, BasicAuth]): + """ + Initialize the Uber class. + """ + self.NON_ENDPOINTS = ["url_type"] # may expand over time.. your move, Qualys. + self.auth = auth + self.valid_endpoints = [ + i for i in CALL_SCHEMA["gav"].keys() if i not in self.NON_ENDPOINTS + ] # grab actual endpoints + + # check if the auth type is valid: + if self.auth.auth_type not in ["token", "basic"]: + raise InvalidAuthTypeError( + f"Invalid auth type: {self.auth.auth_type}. Acceptable auth types are: 'token' or 'basic'." + ) + + def __str__(self) -> str: + return f"Uber class for GAV API with auth type: {self.auth.auth_type}." + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + pass + + def get( + self, + endpoint: Literal[ + "count_assets", "get_all_assets", "get_asset", "query_assets" + ], + **kwargs, + ): + """ + Call the appropriate endpoint based on passed endpoint. + + Params: + endpoint (str): The endpoint to call. Must be one of the keys in CALL_SCHEMA["gav"]. + **kwargs: The keyword arguments to pass to the endpoint. + """ + + match endpoint: + case "count_assets": + return count_assets(self.auth, **kwargs) + case "get_all_assets": + return get_all_assets(self.auth, **kwargs) + case "get_asset": + return get_asset(self.auth, **kwargs) + case "query_assets": + return query_assets(self.auth, **kwargs) + case _: + raise InvalidEndpointError( + f"Invalid endpoint: {endpoint}. Acceptable endpoints are: {self.valid_endpoints}." + ) diff --git a/qualyspy/help.py b/qualyspy/help.py index b70a2eb..f8993ca 100644 --- a/qualyspy/help.py +++ b/qualyspy/help.py @@ -1,47 +1,47 @@ -""" -help.py - Gives help on the different endpoints and what to expect from them. -""" - -from json import dumps -from typing import Union - -from .base.call_schema import CALL_SCHEMA - - -def schema_query( - module: str, endpoint: str = None, pretty: bool = False -) -> Union[str, dict]: - """ - Using the CALL_SCHEMA dictionary, this function will return the information for - either a qualys module as a whole or a specific endpoint within a module. - - Args: - module (str): The module to get help for. - endpoint (str) [optional]: The endpoint to get help for. - pretty (bool) [optional]: Whether to return the information as a string or as a dictionary. [False = return dict, True = return indented str] - """ - - module = module.lower() - endpoint = endpoint.lower() if endpoint is not None else None - - # check if the module is in the CALL_SCHEMA: - if module not in CALL_SCHEMA.keys(): - raise ValueError( - f"Invalid module {module}. Available modules are: {CALL_SCHEMA.keys()}" - ) - - # if the endpoint is not provided, return the entire module schema: - if endpoint is None: - if pretty: - return print(dumps(CALL_SCHEMA[module], indent=4)) - else: - return CALL_SCHEMA[module] - - # if the endpoint is provided, return the schema for that endpoint: - if endpoint not in CALL_SCHEMA[module].keys(): - raise ValueError(f"Invalid endpoint {endpoint} for module {module}.") - - if pretty: - return print(dumps(CALL_SCHEMA[module][endpoint], indent=4)) - else: - return CALL_SCHEMA[module][endpoint] +""" +help.py - Gives help on the different endpoints and what to expect from them. +""" + +from json import dumps +from typing import Union + +from .base.call_schema import CALL_SCHEMA + + +def schema_query( + module: str, endpoint: str = None, pretty: bool = False +) -> Union[str, dict]: + """ + Using the CALL_SCHEMA dictionary, this function will return the information for + either a qualys module as a whole or a specific endpoint within a module. + + Params: + module (str): The module to get help for. + endpoint (str) [optional]: The endpoint to get help for. + pretty (bool) [optional]: Whether to return the information as a string or as a dictionary. [False = return dict, True = return indented str] + """ + + module = module.lower() + endpoint = endpoint.lower() if endpoint is not None else None + + # check if the module is in the CALL_SCHEMA: + if module not in CALL_SCHEMA.keys(): + raise ValueError( + f"Invalid module {module}. Available modules are: {CALL_SCHEMA.keys()}" + ) + + # if the endpoint is not provided, return the entire module schema: + if endpoint is None: + if pretty: + return print(dumps(CALL_SCHEMA[module], indent=4)) + else: + return CALL_SCHEMA[module] + + # if the endpoint is provided, return the schema for that endpoint: + if endpoint not in CALL_SCHEMA[module].keys(): + raise ValueError(f"Invalid endpoint {endpoint} for module {module}.") + + if pretty: + return print(dumps(CALL_SCHEMA[module][endpoint], indent=4)) + else: + return CALL_SCHEMA[module][endpoint] diff --git a/qualyspy/vmdr/__init__.py b/qualyspy/vmdr/__init__.py index 73feb60..ba0f9a0 100644 --- a/qualyspy/vmdr/__init__.py +++ b/qualyspy/vmdr/__init__.py @@ -1,30 +1,30 @@ -""" -VMDR (Vulnerability Management) module - -This module contains ways to interact with the Qualys VMDR API. Valid endpoints are defined in the CALL_SCHEMA dictionary. - -VMDR QQL Syntax help: https://qualysguard.qg2.apps.qualys.com/portal-help/en/vm/search/how_to_search.htm -""" - -from .data_classes import ( - Software, - VendorReference, - CVEID, - KBEntry, - Bugtraq, - ThreatIntel, - Compliance, - Tag, - CloudTag, - Detection, - QDSFactor, - QDS, -) -from .data_classes.lists import BaseList - -from .query_kb import query_kb -from .get_host_list import get_host_list -from .get_host_list_detections import get_hld -from .ips import get_ip_list, add_ips, update_ips -from .assetgroups import * -from .vmscans import get_scan_list +""" +VMDR (Vulnerability Management) module + +This module contains ways to interact with the Qualys VMDR API. Valid endpoints are defined in the CALL_SCHEMA dictionary. + +VMDR QQL Syntax help: https://qualysguard.qg2.apps.qualys.com/portal-help/en/vm/search/how_to_search.htm +""" + +from .data_classes import ( + Software, + VendorReference, + CVEID, + KBEntry, + Bugtraq, + ThreatIntel, + Compliance, + Tag, + CloudTag, + Detection, + QDSFactor, + QDS, +) +from .data_classes.lists import BaseList + +from .query_kb import query_kb +from .get_host_list import get_host_list +from .get_host_list_detections import get_hld +from .ips import get_ip_list, add_ips, update_ips +from .assetgroups import * +from .vmscans import get_scan_list, launch_scan diff --git a/qualyspy/vmdr/assetgroups.py b/qualyspy/vmdr/assetgroups.py index 4e50ad2..d1640fa 100644 --- a/qualyspy/vmdr/assetgroups.py +++ b/qualyspy/vmdr/assetgroups.py @@ -1,340 +1,340 @@ -""" -assetgroups.py - AG manipulation functions for the Qualys VMDR module. -""" - -from typing import * -from urllib.parse import parse_qs, urlparse -from ipaddress import IPv4Address, IPv6Address - -from ..auth import BasicAuth -from .data_classes import AssetGroup, BaseList -from ..base import * - - -def get_ag_list( - auth: BasicAuth, page_count: Union["all", int] = "all", **kwargs -) -> list[AssetGroup]: - """ - Gets a list of asset groups from the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - page_count (Union["all", int]): The number of pages to retrieve. Defaults to "all". If an integer is passed, that number of pages will be retrieved. - - Keyword Args: - ``` - action (str): Action to perform on the asset groups. Defaults to "list". WARNING: SDK automatically sets this value to list. It is just included for completeness. - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. - output_format (Literal["csv", "xml"]): The output format of the response. Defaults to "xml". WARNING: SDK automatically sets this value to xml. It is just included for completeness. - ids (str): The ID of the asset group to get. Defaults to None (all), but can be a single ID or a comma-separated string of IDs. - id_min (int): The minimum ID of the asset group to get. Defaults to None. - id_max (int): The maximum ID of the asset group to get. Defaults to None. - truncation_limit (int): The truncation limit of the asset groups. Defaults to all records. 0 indicates all records. If non-0, the response will be truncated to the specified number of records. Pagination is handled automatically. - network_ids (str): The network IDs of the asset groups to get. Defaults to None. Can be a single ID or a comma-separated string of IDs. WARNING: This has to be enabled in the Qualys subscription! - unit_id (str): The unit ID of the asset groups to get. Defaults to None. Must be a single ID. - user_id (str): The user ID of the asset groups to get. Defaults to None. Must be a single ID. - title (str): The title of the asset groups to get. Defaults to None. Must be an exact match. - show_attributes (Union[None, str]): Choose what attributes are returned. Defaults to None (show basic attrs), can be "ALL", "ID", "TITLE", .. For full list, see Qualys documentation: https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf - ``` - Returns: - BaseList[AssetGroup]: BaseList object containing the AssetGroup objects. - """ - - if type(page_count) in [int, float] and page_count <= 0: - raise ValueError("page_count must be 'all' or an integer greater than 0.") - - results = BaseList() - pulled = 0 - - while True: - kwargs["action"] = "list" - kwargs["echo_request"] = False - kwargs["output_format"] = "xml" - - # Enforce uppercase for show_attributes: - if kwargs.get("show_attributes"): - kwargs["show_attributes"] = kwargs["show_attributes"].upper() - - response = call_api( - auth=auth, - module="vmdr", - endpoint="get_ag_list", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - if response.status_code == 200: - data = xml_parser(response.text)["ASSET_GROUP_LIST_OUTPUT"] - - if "ASSET_GROUP" not in data["RESPONSE"]["ASSET_GROUP_LIST"]: - print("No asset groups found. Returning empty BaseList.") - break - - # Check if type(data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"]) is dict. - # If so, put it inside a list to normalize the class instantiation. - if isinstance(data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"], dict): - data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"] = [ - data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"] - ] - - for ag in data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"]: - results.append(AssetGroup(**ag)) - - pulled += 1 - # Check page count: - if page_count != "all" and pulled >= page_count: - print(f"Page count reached. Returning {pulled} pages.") - break - - # Check for pagination: - if data["RESPONSE"].get("WARNING"): - # Get the id_min param: - url = data["RESPONSE"]["WARNING"]["URL"] - parsed_url = urlparse(url) - id_min = parse_qs(parsed_url.query)["id_min"][0] - kwargs["id_min"] = id_min - print(f"Pagination detected. new id_min param: {id_min}") - else: - break - - return results - - -def manage_ag( - auth: BasicAuth, - action: Literal["add", "edit", "delete"], - id: Union[AssetGroup, BaseList, str] = None, - **kwargs, -) -> str: - """ - Main function to perform an action on an asset group. - - Gets called by the add_ag, edit_ag, and delete_ag functions. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - action (Literal["add", "edit", "delete"]): The action to perform on the asset group. - id (Union[AssetGroup, BaseList, str]): The ID of the asset group to edit or delete. Defaults to None. If a single AssetGroup, or a BaseList of AssetGroups is passed, the function will iterate over them. - - Keyword Args: - ``` - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. - - (WHEN action=="add"): - title (str): Title of the asset group. Required. - comments (str): Comments for the asset group. - division (str): Division of the asset group. - function (str): Function of the asset group. - location (str): Location of the asset group. - business_impact (Literal["critical", "high", "medium", "low", "none"]): Business impact of the asset group. - ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. - default_appliance_id (int): Default appliance ID for the asset group. - domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. - dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. - netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. - cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): CVSS environmental CDP for the asset group. - cvss_enviro_td (Literal["high", "medium", "low", "none"]): CVSS environmental TD for the asset group. - cvss_enviro_cr (Literal["high", "medium", "low"]): CVSS environmental CR for the asset group. - cvss_enviro_ir (Literal["high", "medium", "low"]): CVSS environmental IR for the asset group. - cvss_enviro_ar (Literal["high", "medium", "low"]): CVSS environmental AR for the asset group. - - (WHEN action=="edit"): - set_comments (str): New comments for the asset group. - set_division (str): New division for the asset group. - set_function (str): New function for the asset group. - set_location (str): New location for the asset group. - set_business_impact (Literal["critical", "high", "medium", "low", "none"]): New business impact for the asset group. - add_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - remove_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to remove from the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - set_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to set for the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - add_appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. - remove_appliance_ids (Union[BaseList[int], str]): Appliance IDs to remove from the asset group. Comma-separated string, or a BaseList of integers. - set_appliance_ids (Union[BaseList[int], str]): Appliance IDs to set for the asset group. Comma-separated string, or a BaseList of integers. - set_default_appliance_id (int): New default appliance ID for the asset group. - add_domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_domains (Union[BaseList[str], str]): Domains to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_domains (Union[BaseList[str], str]): Domains to set for the asset group. Comma-separated string, or a BaseList of strings. - add_dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_dns_names (Union[BaseList[str], str]): DNS names to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_dns_names (Union[BaseList[str], str]): DNS names to set for the asset group. Comma-separated string, or a BaseList of strings. - add_netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_netbios_names (Union[BaseList[str], str]): NetBIOS names to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_netbios_names (Union[BaseList[str], str]): NetBIOS names to set for the asset group. Comma-separated string, or a BaseList of strings. - set_title (str): New title for the asset group. - set_cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): New CVSS environmental CDP for the asset group. - set_cvss_enviro_td (Literal["high", "medium", "low", "none"]): New CVSS environmental TD for the asset group. - set_cvss_enviro_cr (Literal["high", "medium", "low"]): New CVSS environmental CR for the asset group. - set_cvss_enviro_ir (Literal["high", "medium", "low"]): New CVSS environmental IR for the asset group. - set_cvss_enviro_ar (Literal["high", "medium", "low"]): New CVSS environmental AR for the asset group. - - (WHEN action=="delete"): - A single value in the id parameter. - ``` - """ - - # First, check the action: - if action not in ["add", "edit", "delete"]: - raise ValueError("action must be 'add', 'edit', or 'delete'.") - - kwargs["action"] = action - - POSSIBLE_LIST_ARGS = [ - "id", - "ids", - "ips", - "appliance_ids", - "domains", - "dns_names", - "netbios_names", - "add_ips", - "remove_ips", - "set_ips", - "add_appliance_ids", - "remove_appliance_ids", - "set_appliance_ids", - "add_domains", - "remove_domains", - "set_domains", - "add_dns_names", - "remove_dns_names", - "set_dns_names", - "add_netbios_names", - "remove_netbios_names", - "set_netbios_names", - ] - POSSIBLE_IP_OBJ_ARGS = ["ips", "add_ips", "remove_ips", "set_ips"] - - # Look for single IP objects and convert them to strings: - for arg in POSSIBLE_IP_OBJ_ARGS: - if arg in kwargs and any( - isinstance(kwargs[arg], obj) for obj in [IPv4Address, IPv6Address] - ): - kwargs[arg] = str(kwargs[arg]) - - # Check if any list args are passed. - # If so, convert them to comma-separated strings: - for arg in POSSIBLE_LIST_ARGS: - if arg in kwargs: - if isinstance(kwargs[arg], BaseList): - kwargs[arg] = [str(item) for item in kwargs[arg]] - kwargs[arg] = ",".join(kwargs[arg]) - - # Check if id is a single AssetGroup object: - if isinstance(id, AssetGroup): - id = id.id - - # Check if id is a BaseList object: - if isinstance(id, BaseList): - id = [str(ag.id) for ag in id] - id = ",".join(id) - - if id: - kwargs["id"] = id - - response = call_api( - auth=auth, - module="vmdr", - endpoint="manage_ag", - payload=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] - - return result - - -def add_ag(auth: BasicAuth, title: str, **kwargs) -> str: - """ - Adds an asset group to the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - title (str): Title of the asset group. - - Keyword Args: - ``` - comments (str): Comments for the asset group. - division (str): Division of the asset group. - function (str): Function of the asset group. - location (str): Location of the asset group. - business_impact (Literal["critical", "high", "medium", "low", "none"]): Business impact of the asset group. - ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. - default_appliance_id (int): Default appliance ID for the asset group. - domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. - dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. - netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. - cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): CVSS environmental CDP for the asset group. - cvss_enviro_td (Literal["high", "medium", "low", "none"]): CVSS environmental TD for the asset group. - cvss_enviro_cr (Literal["high", "medium", "low"]): CVSS environmental CR for the asset group. - cvss_enviro_ir (Literal["high", "medium", "low"]): CVSS environmental IR for the asset group. - cvss_enviro_ar (Literal["high", "medium", "low"]): CVSS environmental AR for the asset group. - ``` - Returns: - str: Response text from the Qualys API. - """ - - return manage_ag(auth, action="add", title=title, **kwargs) - - -def edit_ag(auth: BasicAuth, id: Union[AssetGroup, BaseList, str], **kwargs) -> str: - """ - Edits an asset group in the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - id (Union[AssetGroup, BaseList, str]): The ID of the asset group to edit. If a single AssetGroup, or a BaseList of AssetGroups is passed, the function will iterate over them. - - Keyword Args: - ``` - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. - set_comments (str): New comments for the asset group. - set_division (str): New division for the asset group. - set_function (str): New function for the asset group. - set_location (str): New location for the asset group. - set_business_impact (Literal["critical", "high", "medium", "low", "none"]): New business impact for the asset group. - add_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - remove_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to remove from the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - set_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to set for the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. - add_appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. - remove_appliance_ids (Union[BaseList[int], str]): Appliance IDs to remove from the asset group. Comma-separated string, or a BaseList of integers. - set_appliance_ids (Union[BaseList[int], str]): Appliance IDs to set for the asset group. Comma-separated string, or a BaseList of integers. - set_default_appliance_id (int): New default appliance ID for the asset group. - add_domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_domains (Union[BaseList[str], str]): Domains to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_domains (Union[BaseList[str], str]): Domains to set for the asset group. Comma-separated string, or a BaseList of strings. - add_dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_dns_names (Union[BaseList[str], str]): DNS names to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_dns_names (Union[BaseList[str], str]): DNS names to set for the asset group. Comma-separated string, or a BaseList of strings. - add_netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. - remove_netbios_names (Union[BaseList[str], str]): NetBIOS names to remove from the asset group. Comma-separated string, or a BaseList of strings. - set_netbios_names (Union[BaseList[str], str]): NetBIOS names to set for the asset group. Comma-separated string, or a BaseList of strings. - set_title (str): New title for the asset group. - set_cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): New CVSS environmental CDP for the asset group. - set_cvss_enviro_td (Literal["high", "medium", "low", "none"]): New CVSS environmental TD for the asset group. - set_cvss_enviro_cr (Literal["high", "medium", "low"]): New CVSS environmental CR for the asset group. - set_cvss_enviro_ir (Literal["high", "medium", "low"]): New CVSS environmental IR for the asset group. - set_cvss_enviro_ar (Literal["high", "medium", "low"]): New CVSS environmental AR for the asset group. - ``` - Returns: - str: Response text from the Qualys API. - """ - - return manage_ag(auth, action="edit", id=id, **kwargs) - - -def delete_ag(auth: BasicAuth, id: Union[AssetGroup, str]) -> str: - """ - Deletes a single asset group from the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - id (Union[AssetGroup, str]): The ID of the asset group to delete. - - Returns: - str: Response text from the Qualys API. - """ - - return manage_ag(auth, action="delete", id=id) +""" +assetgroups.py - AG manipulation functions for the Qualys VMDR module. +""" + +from typing import * +from urllib.parse import parse_qs, urlparse +from ipaddress import IPv4Address, IPv6Address + +from ..auth import BasicAuth +from .data_classes import AssetGroup, BaseList +from ..base import * + + +def get_ag_list( + auth: BasicAuth, page_count: Union["all", int] = "all", **kwargs +) -> list[AssetGroup]: + """ + Gets a list of asset groups from the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + page_count (Union["all", int]): The number of pages to retrieve. Defaults to "all". If an integer is passed, that number of pages will be retrieved. + + Keyword Args: + ``` + action (str): Action to perform on the asset groups. Defaults to "list". WARNING: SDK automatically sets this value to list. It is just included for completeness. + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. + output_format (Literal["csv", "xml"]): The output format of the response. Defaults to "xml". WARNING: SDK automatically sets this value to xml. It is just included for completeness. + ids (str): The ID of the asset group to get. Defaults to None (all), but can be a single ID or a comma-separated string of IDs. + id_min (int): The minimum ID of the asset group to get. Defaults to None. + id_max (int): The maximum ID of the asset group to get. Defaults to None. + truncation_limit (int): The truncation limit of the asset groups. Defaults to all records. 0 indicates all records. If non-0, the response will be truncated to the specified number of records. Pagination is handled automatically. + network_ids (str): The network IDs of the asset groups to get. Defaults to None. Can be a single ID or a comma-separated string of IDs. WARNING: This has to be enabled in the Qualys subscription! + unit_id (str): The unit ID of the asset groups to get. Defaults to None. Must be a single ID. + user_id (str): The user ID of the asset groups to get. Defaults to None. Must be a single ID. + title (str): The title of the asset groups to get. Defaults to None. Must be an exact match. + show_attributes (Union[None, str]): Choose what attributes are returned. Defaults to None (show basic attrs), can be "ALL", "ID", "TITLE", .. For full list, see Qualys documentation: https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf + ``` + Returns: + BaseList[AssetGroup]: BaseList object containing the AssetGroup objects. + """ + + if type(page_count) in [int, float] and page_count <= 0: + raise ValueError("page_count must be 'all' or an integer greater than 0.") + + results = BaseList() + pulled = 0 + + while True: + kwargs["action"] = "list" + kwargs["echo_request"] = False + kwargs["output_format"] = "xml" + + # Enforce uppercase for show_attributes: + if kwargs.get("show_attributes"): + kwargs["show_attributes"] = kwargs["show_attributes"].upper() + + response = call_api( + auth=auth, + module="vmdr", + endpoint="get_ag_list", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + if response.status_code == 200: + data = xml_parser(response.text)["ASSET_GROUP_LIST_OUTPUT"] + + if "ASSET_GROUP" not in data["RESPONSE"]["ASSET_GROUP_LIST"]: + print("No asset groups found. Returning empty BaseList.") + break + + # Check if type(data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"]) is dict. + # If so, put it inside a list to normalize the class instantiation. + if isinstance(data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"], dict): + data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"] = [ + data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"] + ] + + for ag in data["RESPONSE"]["ASSET_GROUP_LIST"]["ASSET_GROUP"]: + results.append(AssetGroup(**ag)) + + pulled += 1 + # Check page count: + if page_count != "all" and pulled >= page_count: + print(f"Page count reached. Returning {pulled} pages.") + break + + # Check for pagination: + if data["RESPONSE"].get("WARNING"): + # Get the id_min param: + url = data["RESPONSE"]["WARNING"]["URL"] + parsed_url = urlparse(url) + id_min = parse_qs(parsed_url.query)["id_min"][0] + kwargs["id_min"] = id_min + print(f"Pagination detected. new id_min param: {id_min}") + else: + break + + return results + + +def manage_ag( + auth: BasicAuth, + action: Literal["add", "edit", "delete"], + id: Union[AssetGroup, BaseList, str] = None, + **kwargs, +) -> str: + """ + Main function to perform an action on an asset group. + + Gets called by the add_ag, edit_ag, and delete_ag functions. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + action (Literal["add", "edit", "delete"]): The action to perform on the asset group. + id (Union[AssetGroup, BaseList, str]): The ID of the asset group to edit or delete. Defaults to None. If a single AssetGroup, or a BaseList of AssetGroups is passed, the function will iterate over them. + + Keyword Args: + ``` + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. + + (WHEN action=="add"): + title (str): Title of the asset group. Required. + comments (str): Comments for the asset group. + division (str): Division of the asset group. + function (str): Function of the asset group. + location (str): Location of the asset group. + business_impact (Literal["critical", "high", "medium", "low", "none"]): Business impact of the asset group. + ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. + default_appliance_id (int): Default appliance ID for the asset group. + domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. + dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. + netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. + cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): CVSS environmental CDP for the asset group. + cvss_enviro_td (Literal["high", "medium", "low", "none"]): CVSS environmental TD for the asset group. + cvss_enviro_cr (Literal["high", "medium", "low"]): CVSS environmental CR for the asset group. + cvss_enviro_ir (Literal["high", "medium", "low"]): CVSS environmental IR for the asset group. + cvss_enviro_ar (Literal["high", "medium", "low"]): CVSS environmental AR for the asset group. + + (WHEN action=="edit"): + set_comments (str): New comments for the asset group. + set_division (str): New division for the asset group. + set_function (str): New function for the asset group. + set_location (str): New location for the asset group. + set_business_impact (Literal["critical", "high", "medium", "low", "none"]): New business impact for the asset group. + add_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + remove_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to remove from the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + set_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to set for the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + add_appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. + remove_appliance_ids (Union[BaseList[int], str]): Appliance IDs to remove from the asset group. Comma-separated string, or a BaseList of integers. + set_appliance_ids (Union[BaseList[int], str]): Appliance IDs to set for the asset group. Comma-separated string, or a BaseList of integers. + set_default_appliance_id (int): New default appliance ID for the asset group. + add_domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_domains (Union[BaseList[str], str]): Domains to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_domains (Union[BaseList[str], str]): Domains to set for the asset group. Comma-separated string, or a BaseList of strings. + add_dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_dns_names (Union[BaseList[str], str]): DNS names to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_dns_names (Union[BaseList[str], str]): DNS names to set for the asset group. Comma-separated string, or a BaseList of strings. + add_netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_netbios_names (Union[BaseList[str], str]): NetBIOS names to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_netbios_names (Union[BaseList[str], str]): NetBIOS names to set for the asset group. Comma-separated string, or a BaseList of strings. + set_title (str): New title for the asset group. + set_cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): New CVSS environmental CDP for the asset group. + set_cvss_enviro_td (Literal["high", "medium", "low", "none"]): New CVSS environmental TD for the asset group. + set_cvss_enviro_cr (Literal["high", "medium", "low"]): New CVSS environmental CR for the asset group. + set_cvss_enviro_ir (Literal["high", "medium", "low"]): New CVSS environmental IR for the asset group. + set_cvss_enviro_ar (Literal["high", "medium", "low"]): New CVSS environmental AR for the asset group. + + (WHEN action=="delete"): + A single value in the id parameter. + ``` + """ + + # First, check the action: + if action not in ["add", "edit", "delete"]: + raise ValueError("action must be 'add', 'edit', or 'delete'.") + + kwargs["action"] = action + + POSSIBLE_LIST_ARGS = [ + "id", + "ids", + "ips", + "appliance_ids", + "domains", + "dns_names", + "netbios_names", + "add_ips", + "remove_ips", + "set_ips", + "add_appliance_ids", + "remove_appliance_ids", + "set_appliance_ids", + "add_domains", + "remove_domains", + "set_domains", + "add_dns_names", + "remove_dns_names", + "set_dns_names", + "add_netbios_names", + "remove_netbios_names", + "set_netbios_names", + ] + POSSIBLE_IP_OBJ_ARGS = ["ips", "add_ips", "remove_ips", "set_ips"] + + # Look for single IP objects and convert them to strings: + for arg in POSSIBLE_IP_OBJ_ARGS: + if arg in kwargs and any( + isinstance(kwargs[arg], obj) for obj in [IPv4Address, IPv6Address] + ): + kwargs[arg] = str(kwargs[arg]) + + # Check if any list args are passed. + # If so, convert them to comma-separated strings: + for arg in POSSIBLE_LIST_ARGS: + if arg in kwargs: + if isinstance(kwargs[arg], BaseList): + kwargs[arg] = [str(item) for item in kwargs[arg]] + kwargs[arg] = ",".join(kwargs[arg]) + + # Check if id is a single AssetGroup object: + if isinstance(id, AssetGroup): + id = id.id + + # Check if id is a BaseList object: + if isinstance(id, BaseList): + id = [str(ag.id) for ag in id] + id = ",".join(id) + + if id: + kwargs["id"] = id + + response = call_api( + auth=auth, + module="vmdr", + endpoint="manage_ag", + payload=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] + + return result + + +def add_ag(auth: BasicAuth, title: str, **kwargs) -> str: + """ + Adds an asset group to the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + title (str): Title of the asset group. + + Keyword Args: + ``` + comments (str): Comments for the asset group. + division (str): Division of the asset group. + function (str): Function of the asset group. + location (str): Location of the asset group. + business_impact (Literal["critical", "high", "medium", "low", "none"]): Business impact of the asset group. + ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. + default_appliance_id (int): Default appliance ID for the asset group. + domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. + dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. + netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. + cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): CVSS environmental CDP for the asset group. + cvss_enviro_td (Literal["high", "medium", "low", "none"]): CVSS environmental TD for the asset group. + cvss_enviro_cr (Literal["high", "medium", "low"]): CVSS environmental CR for the asset group. + cvss_enviro_ir (Literal["high", "medium", "low"]): CVSS environmental IR for the asset group. + cvss_enviro_ar (Literal["high", "medium", "low"]): CVSS environmental AR for the asset group. + ``` + Returns: + str: Response text from the Qualys API. + """ + + return manage_ag(auth, action="add", title=title, **kwargs) + + +def edit_ag(auth: BasicAuth, id: Union[AssetGroup, BaseList, str], **kwargs) -> str: + """ + Edits an asset group in the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + id (Union[AssetGroup, BaseList, str]): The ID of the asset group to edit. If a single AssetGroup, or a BaseList of AssetGroups is passed, the function will iterate over them. + + Keyword Args: + ``` + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value to 0. It is just included for completeness. + set_comments (str): New comments for the asset group. + set_division (str): New division for the asset group. + set_function (str): New function for the asset group. + set_location (str): New location for the asset group. + set_business_impact (Literal["critical", "high", "medium", "low", "none"]): New business impact for the asset group. + add_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to add to the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + remove_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to remove from the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + set_ips (Union[str, ipaddress.IPVxAddress, BaseList[ipaddress.IPVxAddress]]): IP addresses to set for the asset group. Comma-separated string, a single ipaddress.IPvxAddress, or a BaseList of ipaddress.IPvxAddress. + add_appliance_ids (Union[BaseList[int], str]): Appliance IDs to add to the asset group. Comma-separated string, or a BaseList of integers. + remove_appliance_ids (Union[BaseList[int], str]): Appliance IDs to remove from the asset group. Comma-separated string, or a BaseList of integers. + set_appliance_ids (Union[BaseList[int], str]): Appliance IDs to set for the asset group. Comma-separated string, or a BaseList of integers. + set_default_appliance_id (int): New default appliance ID for the asset group. + add_domains (Union[BaseList[str], str]): Domains to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_domains (Union[BaseList[str], str]): Domains to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_domains (Union[BaseList[str], str]): Domains to set for the asset group. Comma-separated string, or a BaseList of strings. + add_dns_names (Union[BaseList[str], str]): DNS names to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_dns_names (Union[BaseList[str], str]): DNS names to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_dns_names (Union[BaseList[str], str]): DNS names to set for the asset group. Comma-separated string, or a BaseList of strings. + add_netbios_names (Union[BaseList[str], str]): NetBIOS names to add to the asset group. Comma-separated string, or a BaseList of strings. + remove_netbios_names (Union[BaseList[str], str]): NetBIOS names to remove from the asset group. Comma-separated string, or a BaseList of strings. + set_netbios_names (Union[BaseList[str], str]): NetBIOS names to set for the asset group. Comma-separated string, or a BaseList of strings. + set_title (str): New title for the asset group. + set_cvss_enviro_cdp (Literal["high", "medium-high", "low-medium", "low", "none"]): New CVSS environmental CDP for the asset group. + set_cvss_enviro_td (Literal["high", "medium", "low", "none"]): New CVSS environmental TD for the asset group. + set_cvss_enviro_cr (Literal["high", "medium", "low"]): New CVSS environmental CR for the asset group. + set_cvss_enviro_ir (Literal["high", "medium", "low"]): New CVSS environmental IR for the asset group. + set_cvss_enviro_ar (Literal["high", "medium", "low"]): New CVSS environmental AR for the asset group. + ``` + Returns: + str: Response text from the Qualys API. + """ + + return manage_ag(auth, action="edit", id=id, **kwargs) + + +def delete_ag(auth: BasicAuth, id: Union[AssetGroup, str]) -> str: + """ + Deletes a single asset group from the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + id (Union[AssetGroup, str]): The ID of the asset group to delete. + + Returns: + str: Response text from the Qualys API. + """ + + return manage_ag(auth, action="delete", id=id) diff --git a/qualyspy/vmdr/data_classes/__init__.py b/qualyspy/vmdr/data_classes/__init__.py index e7d31dc..8dc37f3 100644 --- a/qualyspy/vmdr/data_classes/__init__.py +++ b/qualyspy/vmdr/data_classes/__init__.py @@ -1,19 +1,19 @@ -""" -dataclasses module for VMDR module - contains the dataclasses for the VMDR module. -""" - -from .software import Software -from .vendor_reference import VendorReference -from .kb_entry import KBEntry -from .bugtraq import Bugtraq -from .cve import CVEID -from .threat_intel import ThreatIntel -from .compliance import Compliance -from .tag import Tag, CloudTag -from .detection import Detection -from .qds_factor import QDSFactor -from .qds import QDS -from .asset_group import AssetGroup -from qualyspy.vmdr.data_classes.vmscan import VMScan - -from .lists import BaseList +""" +dataclasses module for VMDR module - contains the dataclasses for the VMDR module. +""" + +from .software import Software +from .vendor_reference import VendorReference +from .kb_entry import KBEntry +from .bugtraq import Bugtraq +from .cve import CVEID +from .threat_intel import ThreatIntel +from .compliance import Compliance +from .tag import Tag, CloudTag +from .detection import Detection +from .qds_factor import QDSFactor +from .qds import QDS +from .asset_group import AssetGroup +from qualyspy.vmdr.data_classes.vmscan import VMScan + +from .lists import BaseList diff --git a/qualyspy/vmdr/data_classes/asset_group.py b/qualyspy/vmdr/data_classes/asset_group.py index 9f99213..eff3d98 100644 --- a/qualyspy/vmdr/data_classes/asset_group.py +++ b/qualyspy/vmdr/data_classes/asset_group.py @@ -1,317 +1,317 @@ -""" -asset_group.py - contains the AssetGroup dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * -from warnings import filterwarnings -from datetime import datetime -from ipaddress import ( - IPv4Address, - IPv6Address, - IPv4Network, - IPv6Network, -) - -from .ip_converters import * -from .hosts import VMDRID -from .lists.base_list import BaseList - - -@dataclass(order=True) -class AssetGroup: - """ - AssetGroup - represents a single asset group in Qualys. - """ - - ID: int = field(metadata={"description": "The ID of the asset group."}) - TITLE: Optional[str] = field( - metadata={"description": "The title of the asset group."}, default="" - ) - # NOTE: OWNER_ID contains either the raw XML's OWNER_ID or OWNER_USER_ID depending on value passed to get_ag_list(attributes=<>). - OWNER_ID: Optional[int] = field( - metadata={"description": "The ID of the owner of the asset group."}, - default=None, - ) - OWNER_USER_ID: Optional[int] = field( - metadata={"description": "The ID of the owner user of the asset group."}, - default=None, - ) - UNIT_ID: Optional[int] = field( - metadata={"description": "The ID of the unit of the asset group."}, default=None - ) - LAST_UPDATE: Optional[Union[str, datetime]] = field( - metadata={"description": "The datetime the asset group was last updated."}, - default=None, - ) - NETWORK_ID: Optional[int] = field( - metadata={ - "description": "The ID of the network of the asset group, if enabled in Qualys." - }, - default=None, - ) - IP_SET: Optional[ - BaseList[Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]] - ] = field( - metadata={"description": "The IP set of the asset group."}, - default_factory=BaseList, - ) - BUSINESS_IMPACT: Optional[str] = field( - metadata={"description": "The business impact of the asset group."}, - default=None, - ) - DEFAULT_APPLIANCE_ID: Optional[int] = field( - metadata={"description": "The default appliance ID of the asset group."}, - default=None, - ) - APPLIANCE_IDS: Optional[BaseList[int]] = field( - metadata={"description": "The appliance IDs of the asset group."}, - default_factory=BaseList, - ) - DNS_LIST: Optional[BaseList[str]] = field( - metadata={"description": "The DNS list of the asset group."}, - default_factory=BaseList, - ) - NETBIOS_LIST: Optional[BaseList[str]] = field( - metadata={"description": "The NetBIOS list of the asset group."}, - default_factory=BaseList, - ) - HOST_IDS: Optional[BaseList[VMDRID]] = field( - metadata={ - "description": "The host IDs of the asset group. BaseList of VMDRID objects." - }, - default_factory=BaseList, - ) - ASSIGNED_USER_IDS: Optional[BaseList[int]] = field( - metadata={"description": "The assigned user IDs of the asset group."}, - default_factory=BaseList, - ) - ASSIGNED_UNIT_IDS: Optional[BaseList[int]] = field( - metadata={"description": "The assigned unit IDs of the asset group."}, - default_factory=BaseList, - ) - OWNER_USER_NAME: Optional[str] = field( - metadata={"description": "The owner user name of the asset group."}, - default=None, - ) - CVSS_ENVIRO_CDP: Optional[str] = field( - metadata={"description": "The CVSS environmental CDP of the asset group."}, - default=None, - ) - CVSS_ENVIRO_TD: Optional[str] = field( - metadata={"description": "The CVSS environmental TD of the asset group."}, - default=None, - ) - CVSS_ENVIRO_CR: Optional[str] = field( - metadata={"description": "The CVSS environmental CR of the asset group."}, - default=None, - ) - CVSS_ENVIRO_IR: Optional[str] = field( - metadata={"description": "The CVSS environmental IR of the asset group."}, - default=None, - ) - CVSS_ENVIRO_AR: Optional[str] = field( - metadata={"description": "The CVSS environmental AR of the asset group."}, - default=None, - ) - EC2_IDS: Optional[BaseList[str]] = field( - metadata={"description": "The EC2 IDs of the asset group."}, - default_factory=BaseList, - ) - COMMENTS: Optional[BaseList[str]] = field( - metadata={"description": "The comments of the asset group."}, - default_factory=BaseList, - ) - DOMAIN_LIST: Optional[BaseList[str]] = field( - metadata={"description": "The domain list of the asset group."}, - default_factory=BaseList, - ) - - def __post_init__(self): - # Thanks Qualys for the inconsistency in naming conventions: - if self.OWNER_USER_ID: - self.OWNER_ID = self.OWNER_USER_ID - del self.OWNER_USER_ID - - # Do data conversions: - INT_FIELDS = ["ID", "OWNER_ID", "UNIT_ID", "NETWORK_ID", "DEFAULT_APPLIANCE_ID"] - DT_FIELDS = ["LAST_UPDATE"] - INT_LISTS = ["APPLIANCE_IDS", "ASSIGNED_USER_IDS", "ASSIGNED_UNIT_IDS"] - STR_LISTS = ["DNS_LIST", "NETBIOS_LIST", "EC2_IDS", "COMMENTS", "DOMAIN_LIST"] - - for field in STR_LISTS: - if getattr(self, field): - setattr(self, field, BaseList(getattr(self, field).split(","))) - - for field in INT_LISTS: - if getattr(self, field): - if isinstance(getattr(self, field), str): - setattr( - self, - field, - BaseList([int(x) for x in getattr(self, field).split(",")]), - ) - else: - setattr( - self, field, BaseList([int(x) for x in getattr(self, field)]) - ) - - for field in DT_FIELDS: - if getattr(self, field) is not None: - setattr(self, field, datetime.fromisoformat(getattr(self, field))) - - for field in INT_FIELDS: - if getattr(self, field) is not None: - setattr(self, field, int(getattr(self, field))) - - # Convert IP_SET to BaseList of ipaddress.* objs.: - final_ip_set = BaseList() - if self.IP_SET: - if self.IP_SET.get("IP_RANGE"): - if isinstance(self.IP_SET.get("IP_RANGE"), str): - # We can use single_range here because it's a single IP range. - final_ip_set.extend([single_range(self.IP_SET.get("IP_RANGE"))]) - else: - # We can use convert_ranges here because it's a list of IP ranges. - final_ip_set.extend(convert_ranges(self.IP_SET.get("IP_RANGE"))) - - if self.IP_SET.get("IP"): - if isinstance(self.IP_SET.get("IP"), str): - # We can use single_ip here because it's a single IP. - final_ip_set.extend([single_ip(self.IP_SET.get("IP"))]) - else: - # We can use convert_ips here because it's a list of IPs. - final_ip_set.extend(convert_ips(self.IP_SET.get("IP"))) - - self.IP_SET = final_ip_set - - # Convert HOST_IDS to BaseList of VMDRID objs.: - final_host_ids = BaseList() - if self.HOST_IDS: - if isinstance(self.HOST_IDS, str): - final_host_ids.extend( - [ - VMDRID(ID=host_id, TYPE="host") - for host_id in self.HOST_IDS.split(",") - ] - ) - else: - final_host_ids.extend( - [VMDRID(ID=host_id, TYPE="host") for host_id in self.HOST_IDS] - ) - - self.HOST_IDS = final_host_ids - - def __str__(self): - return str(self.ID) - - def __contains__(self, item): - return ( - item in self.ID - or item in self.TITLE - or item in self.OWNER_ID - or item in self.UNIT_ID - or item in self.NETWORK_ID - or item in self.IP_SET - ) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - pass - - def copy(self): - return AssetGroup( - ID=self.ID, - TITLE=self.TITLE, - OWNER_ID=self.OWNER_ID, - UNIT_ID=self.UNIT_ID, - NETWORK_ID=self.NETWORK_ID, - IP_SET=self.IP_SET, - ) - - def is_id(self, id: int): - return self.ID == id - - def is_title(self, title: str): - return self.TITLE == title - - def is_owner_id(self, owner_id: int): - return self.OWNER_ID == owner_id - - def is_unit_id(self, unit_id: int): - return self.UNIT_ID == unit_id - - def is_network_id(self, network_id: int): - return self.NETWORK_ID == network_id - - def contains_ip( - self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network] - ): - return ip in self.IP_SET - - def add_ip(self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]): - self.IP_SET.append(ip) - - def remove_ip(self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]): - self.IP_SET.remove(ip) - - def valid_values(self): - """ - Return a dictionary of non-None attributes. - """ - return { - key: value - for key, value in self.to_dict().items() - if value is not None and value != [] and value != "" - } - - def keys(self): - return self.to_dict().keys() - - def values(self): - return self.to_dict().values() - - def items(self): - return self.to_dict().items() - - def to_dict(self): - """ - to_dict - convert the AssetGroup object to a dictionary. - - This function is used to convert the AssetGroup object to a dictionary. - """ - return { - "ID": self.ID, - "TITLE": self.TITLE, - "OWNER_ID": self.OWNER_ID, - "UNIT_ID": self.UNIT_ID, - "LAST_UPDATE": self.LAST_UPDATE, - "NETWORK_ID": self.NETWORK_ID, - "IP_SET": self.IP_SET, - "BUSINESS_IMPACT": self.BUSINESS_IMPACT, - "DEFAULT_APPLIANCE_ID": self.DEFAULT_APPLIANCE_ID, - "APPLIANCE_IDS": self.APPLIANCE_IDS, - "DNS_LIST": self.DNS_LIST, - "NETBIOS_LIST": self.NETBIOS_LIST, - "HOST_IDS": self.HOST_IDS, - "ASSIGNED_USER_IDS": self.ASSIGNED_USER_IDS, - "ASSIGNED_UNIT_IDS": self.ASSIGNED_UNIT_IDS, - "OWNER_USER_NAME": self.OWNER_USER_NAME, - } - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create an AssetGroup object from a dictionary. - - This function is used to create an AssetGroup object from a dictionary. - """ - required_keys = {"ID", "TITLE"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +asset_group.py - contains the AssetGroup dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * +from warnings import filterwarnings +from datetime import datetime +from ipaddress import ( + IPv4Address, + IPv6Address, + IPv4Network, + IPv6Network, +) + +from .ip_converters import * +from .hosts import VMDRID +from .lists.base_list import BaseList + + +@dataclass(order=True) +class AssetGroup: + """ + AssetGroup - represents a single asset group in Qualys. + """ + + ID: int = field(metadata={"description": "The ID of the asset group."}) + TITLE: Optional[str] = field( + metadata={"description": "The title of the asset group."}, default="" + ) + # NOTE: OWNER_ID contains either the raw XML's OWNER_ID or OWNER_USER_ID depending on value passed to get_ag_list(attributes=<>). + OWNER_ID: Optional[int] = field( + metadata={"description": "The ID of the owner of the asset group."}, + default=None, + ) + OWNER_USER_ID: Optional[int] = field( + metadata={"description": "The ID of the owner user of the asset group."}, + default=None, + ) + UNIT_ID: Optional[int] = field( + metadata={"description": "The ID of the unit of the asset group."}, default=None + ) + LAST_UPDATE: Optional[Union[str, datetime]] = field( + metadata={"description": "The datetime the asset group was last updated."}, + default=None, + ) + NETWORK_ID: Optional[int] = field( + metadata={ + "description": "The ID of the network of the asset group, if enabled in Qualys." + }, + default=None, + ) + IP_SET: Optional[ + BaseList[Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]] + ] = field( + metadata={"description": "The IP set of the asset group."}, + default_factory=BaseList, + ) + BUSINESS_IMPACT: Optional[str] = field( + metadata={"description": "The business impact of the asset group."}, + default=None, + ) + DEFAULT_APPLIANCE_ID: Optional[int] = field( + metadata={"description": "The default appliance ID of the asset group."}, + default=None, + ) + APPLIANCE_IDS: Optional[BaseList[int]] = field( + metadata={"description": "The appliance IDs of the asset group."}, + default_factory=BaseList, + ) + DNS_LIST: Optional[BaseList[str]] = field( + metadata={"description": "The DNS list of the asset group."}, + default_factory=BaseList, + ) + NETBIOS_LIST: Optional[BaseList[str]] = field( + metadata={"description": "The NetBIOS list of the asset group."}, + default_factory=BaseList, + ) + HOST_IDS: Optional[BaseList[VMDRID]] = field( + metadata={ + "description": "The host IDs of the asset group. BaseList of VMDRID objects." + }, + default_factory=BaseList, + ) + ASSIGNED_USER_IDS: Optional[BaseList[int]] = field( + metadata={"description": "The assigned user IDs of the asset group."}, + default_factory=BaseList, + ) + ASSIGNED_UNIT_IDS: Optional[BaseList[int]] = field( + metadata={"description": "The assigned unit IDs of the asset group."}, + default_factory=BaseList, + ) + OWNER_USER_NAME: Optional[str] = field( + metadata={"description": "The owner user name of the asset group."}, + default=None, + ) + CVSS_ENVIRO_CDP: Optional[str] = field( + metadata={"description": "The CVSS environmental CDP of the asset group."}, + default=None, + ) + CVSS_ENVIRO_TD: Optional[str] = field( + metadata={"description": "The CVSS environmental TD of the asset group."}, + default=None, + ) + CVSS_ENVIRO_CR: Optional[str] = field( + metadata={"description": "The CVSS environmental CR of the asset group."}, + default=None, + ) + CVSS_ENVIRO_IR: Optional[str] = field( + metadata={"description": "The CVSS environmental IR of the asset group."}, + default=None, + ) + CVSS_ENVIRO_AR: Optional[str] = field( + metadata={"description": "The CVSS environmental AR of the asset group."}, + default=None, + ) + EC2_IDS: Optional[BaseList[str]] = field( + metadata={"description": "The EC2 IDs of the asset group."}, + default_factory=BaseList, + ) + COMMENTS: Optional[BaseList[str]] = field( + metadata={"description": "The comments of the asset group."}, + default_factory=BaseList, + ) + DOMAIN_LIST: Optional[BaseList[str]] = field( + metadata={"description": "The domain list of the asset group."}, + default_factory=BaseList, + ) + + def __post_init__(self): + # Thanks Qualys for the inconsistency in naming conventions: + if self.OWNER_USER_ID: + self.OWNER_ID = self.OWNER_USER_ID + del self.OWNER_USER_ID + + # Do data conversions: + INT_FIELDS = ["ID", "OWNER_ID", "UNIT_ID", "NETWORK_ID", "DEFAULT_APPLIANCE_ID"] + DT_FIELDS = ["LAST_UPDATE"] + INT_LISTS = ["APPLIANCE_IDS", "ASSIGNED_USER_IDS", "ASSIGNED_UNIT_IDS"] + STR_LISTS = ["DNS_LIST", "NETBIOS_LIST", "EC2_IDS", "COMMENTS", "DOMAIN_LIST"] + + for field in STR_LISTS: + if getattr(self, field): + setattr(self, field, BaseList(getattr(self, field).split(","))) + + for field in INT_LISTS: + if getattr(self, field): + if isinstance(getattr(self, field), str): + setattr( + self, + field, + BaseList([int(x) for x in getattr(self, field).split(",")]), + ) + else: + setattr( + self, field, BaseList([int(x) for x in getattr(self, field)]) + ) + + for field in DT_FIELDS: + if getattr(self, field) is not None: + setattr(self, field, datetime.fromisoformat(getattr(self, field))) + + for field in INT_FIELDS: + if getattr(self, field) is not None: + setattr(self, field, int(getattr(self, field))) + + # Convert IP_SET to BaseList of ipaddress.* objs.: + final_ip_set = BaseList() + if self.IP_SET: + if self.IP_SET.get("IP_RANGE"): + if isinstance(self.IP_SET.get("IP_RANGE"), str): + # We can use single_range here because it's a single IP range. + final_ip_set.extend([single_range(self.IP_SET.get("IP_RANGE"))]) + else: + # We can use convert_ranges here because it's a list of IP ranges. + final_ip_set.extend(convert_ranges(self.IP_SET.get("IP_RANGE"))) + + if self.IP_SET.get("IP"): + if isinstance(self.IP_SET.get("IP"), str): + # We can use single_ip here because it's a single IP. + final_ip_set.extend([single_ip(self.IP_SET.get("IP"))]) + else: + # We can use convert_ips here because it's a list of IPs. + final_ip_set.extend(convert_ips(self.IP_SET.get("IP"))) + + self.IP_SET = final_ip_set + + # Convert HOST_IDS to BaseList of VMDRID objs.: + final_host_ids = BaseList() + if self.HOST_IDS: + if isinstance(self.HOST_IDS, str): + final_host_ids.extend( + [ + VMDRID(ID=host_id, TYPE="host") + for host_id in self.HOST_IDS.split(",") + ] + ) + else: + final_host_ids.extend( + [VMDRID(ID=host_id, TYPE="host") for host_id in self.HOST_IDS] + ) + + self.HOST_IDS = final_host_ids + + def __str__(self): + return str(self.ID) + + def __contains__(self, item): + return ( + item in self.ID + or item in self.TITLE + or item in self.OWNER_ID + or item in self.UNIT_ID + or item in self.NETWORK_ID + or item in self.IP_SET + ) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + pass + + def copy(self): + return AssetGroup( + ID=self.ID, + TITLE=self.TITLE, + OWNER_ID=self.OWNER_ID, + UNIT_ID=self.UNIT_ID, + NETWORK_ID=self.NETWORK_ID, + IP_SET=self.IP_SET, + ) + + def is_id(self, id: int): + return self.ID == id + + def is_title(self, title: str): + return self.TITLE == title + + def is_owner_id(self, owner_id: int): + return self.OWNER_ID == owner_id + + def is_unit_id(self, unit_id: int): + return self.UNIT_ID == unit_id + + def is_network_id(self, network_id: int): + return self.NETWORK_ID == network_id + + def contains_ip( + self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network] + ): + return ip in self.IP_SET + + def add_ip(self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]): + self.IP_SET.append(ip) + + def remove_ip(self, ip: Union[IPv4Address, IPv6Address, IPv4Network, IPv6Network]): + self.IP_SET.remove(ip) + + def valid_values(self): + """ + Return a dictionary of non-None attributes. + """ + return { + key: value + for key, value in self.to_dict().items() + if value is not None and value != [] and value != "" + } + + def keys(self): + return self.to_dict().keys() + + def values(self): + return self.to_dict().values() + + def items(self): + return self.to_dict().items() + + def to_dict(self): + """ + to_dict - convert the AssetGroup object to a dictionary. + + This function is used to convert the AssetGroup object to a dictionary. + """ + return { + "ID": self.ID, + "TITLE": self.TITLE, + "OWNER_ID": self.OWNER_ID, + "UNIT_ID": self.UNIT_ID, + "LAST_UPDATE": self.LAST_UPDATE, + "NETWORK_ID": self.NETWORK_ID, + "IP_SET": self.IP_SET, + "BUSINESS_IMPACT": self.BUSINESS_IMPACT, + "DEFAULT_APPLIANCE_ID": self.DEFAULT_APPLIANCE_ID, + "APPLIANCE_IDS": self.APPLIANCE_IDS, + "DNS_LIST": self.DNS_LIST, + "NETBIOS_LIST": self.NETBIOS_LIST, + "HOST_IDS": self.HOST_IDS, + "ASSIGNED_USER_IDS": self.ASSIGNED_USER_IDS, + "ASSIGNED_UNIT_IDS": self.ASSIGNED_UNIT_IDS, + "OWNER_USER_NAME": self.OWNER_USER_NAME, + } + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create an AssetGroup object from a dictionary. + + This function is used to create an AssetGroup object from a dictionary. + """ + required_keys = {"ID", "TITLE"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/bugtraq.py b/qualyspy/vmdr/data_classes/bugtraq.py index 2a567e9..279fc16 100644 --- a/qualyspy/vmdr/data_classes/bugtraq.py +++ b/qualyspy/vmdr/data_classes/bugtraq.py @@ -1,56 +1,56 @@ -""" -bugtraq.py - contains the BugTraq dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class Bugtraq: - """ - BugTraq - represents a single BugTraq entry in a BugTraqList. - """ - - ID: int = field(metadata={"description": "The BugTraq ID."}) - URL: str = field( - metadata={"description": "The URL of the BugTraq."}, default="", compare=False - ) - - def __post_init__(self): - # make sure that the ID is an integer: - if not isinstance(self.ID, int): - raise TypeError(f"BugTraq ID must be an integer, not {type(self.ID)}") - # and that url is a string: - if not isinstance(self.URL, str): - raise TypeError(f"BugTraq URL must be a string, not {type(self.URL)}") - - def __str__(self): - return str(self.ID) - - def __contains__(self, item): - return item in self.ID or item in self.URL - - def copy(self): - return Bugtraq(ID=self.ID, URL=self.URL) - - def is_id(self, id: int): - return self.ID == id - - def is_url(self, url: str): - return self.URL == url - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a BugTraq object from a dictionary. - - This function is used to create a BugTraq object from a dictionary. - """ - required_keys = {"ID"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +bugtraq.py - contains the BugTraq dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class Bugtraq: + """ + BugTraq - represents a single BugTraq entry in a BugTraqList. + """ + + ID: int = field(metadata={"description": "The BugTraq ID."}) + URL: str = field( + metadata={"description": "The URL of the BugTraq."}, default="", compare=False + ) + + def __post_init__(self): + # make sure that the ID is an integer: + if not isinstance(self.ID, int): + raise TypeError(f"BugTraq ID must be an integer, not {type(self.ID)}") + # and that url is a string: + if not isinstance(self.URL, str): + raise TypeError(f"BugTraq URL must be a string, not {type(self.URL)}") + + def __str__(self): + return str(self.ID) + + def __contains__(self, item): + return item in self.ID or item in self.URL + + def copy(self): + return Bugtraq(ID=self.ID, URL=self.URL) + + def is_id(self, id: int): + return self.ID == id + + def is_url(self, url: str): + return self.URL == url + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a BugTraq object from a dictionary. + + This function is used to create a BugTraq object from a dictionary. + """ + required_keys = {"ID"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/compliance.py b/qualyspy/vmdr/data_classes/compliance.py index f583359..af59cad 100644 --- a/qualyspy/vmdr/data_classes/compliance.py +++ b/qualyspy/vmdr/data_classes/compliance.py @@ -1,57 +1,57 @@ -""" -compliance.py - contains the Compliance dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class Compliance: - """ - Compliance - represents a compliance object. - """ - - _TYPE: str = field(metadata={"description": "The compliance framework name."}) - SECTION: str = field(metadata={"description": "The section name."}) - DESCRIPTION: str = field( - metadata={"description": "The description of the compliance."} - ) - - def __str__(self): - return f"{self._TYPE} - {self.SECTION}" - - def __contains__(self, item): - return item in self._TYPE or item in self.SECTION or item in self.DESCRIPTION - - def copy(self): - return Compliance( - _TYPE=self._TYPE, SECTION=self.SECTION, DESCRIPTION=self.DESCRIPTION - ) - - def is_type(self, _type: str): - return self._TYPE == _type - - def is_section(self, section: str): - return self.SECTION == section - - def is_description(self, description: str): - return self.DESCRIPTION == description - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a Compliance object from a dictionary. - - Args: - data (dict): The dictionary containing the data for the Compliance object. - - Returns: - Compliance: The Compliance object created from the dictionary. - """ - required_keys = {"_TYPE", "SECTION", "DESCRIPTION"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - return cls(**data) +""" +compliance.py - contains the Compliance dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class Compliance: + """ + Compliance - represents a compliance object. + """ + + _TYPE: str = field(metadata={"description": "The compliance framework name."}) + SECTION: str = field(metadata={"description": "The section name."}) + DESCRIPTION: str = field( + metadata={"description": "The description of the compliance."} + ) + + def __str__(self): + return f"{self._TYPE} - {self.SECTION}" + + def __contains__(self, item): + return item in self._TYPE or item in self.SECTION or item in self.DESCRIPTION + + def copy(self): + return Compliance( + _TYPE=self._TYPE, SECTION=self.SECTION, DESCRIPTION=self.DESCRIPTION + ) + + def is_type(self, _type: str): + return self._TYPE == _type + + def is_section(self, section: str): + return self.SECTION == section + + def is_description(self, description: str): + return self.DESCRIPTION == description + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a Compliance object from a dictionary. + + Params: + data (dict): The dictionary containing the data for the Compliance object. + + Returns: + Compliance: The Compliance object created from the dictionary. + """ + required_keys = {"_TYPE", "SECTION", "DESCRIPTION"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/cve.py b/qualyspy/vmdr/data_classes/cve.py index 6eed1c2..6317bd8 100644 --- a/qualyspy/vmdr/data_classes/cve.py +++ b/qualyspy/vmdr/data_classes/cve.py @@ -1,73 +1,73 @@ -""" -cve.py - contains the CVEID dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * -from re import match - - -@dataclass -class CVEID: - """ - CVEID - represents a single CVE ID in a CVEList. - """ - - ID: str = field(metadata={"description": "The ID of the CVE."}, compare=True) - URL: Optional[str] = field( - metadata={"description": "The URL of the CVE."}, default="", compare=False - ) - - def __post_init__(self): - # make sure that the ID is a string: - if not isinstance(self.ID, str): - raise TypeError(f"CVEID ID must be a string, not {type(self.ID)}") - # and that url is a string: - if not isinstance(self.URL, str): - raise TypeError(f"CVEID URL must be a string, not {type(self.URL)}") - - def __str__(self) -> str: - return self.ID - - def __contains__(self, item): - # see if it was found in the name or vendor: - return item in self.ID - - def copy(self): - return CVEID(id=self.ID) - - def is_id(self, id: str): - return self.ID == id - - @classmethod - def from_str(cls, cve_id: str): - """ - from_str - create a CVEID object from a string. - - This function is used to create a CVEID object from a string. - - The string should be in the format "CVE-YYYY-NNNN". - - If the string is not in the correct format, a ValueError will be raised. - """ - cve_regex = r"CVE-\d{4}-\d{4,}" - if not match(cve_regex, cve_id): - raise ValueError(f"Invalid CVE ID format: {cve_id}") - - return cls(ID=cve_id) - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a CVEID object from a dictionary. - - This function is used to create a CVEID object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"ID"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +cve.py - contains the CVEID dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * +from re import match + + +@dataclass +class CVEID: + """ + CVEID - represents a single CVE ID in a CVEList. + """ + + ID: str = field(metadata={"description": "The ID of the CVE."}, compare=True) + URL: Optional[str] = field( + metadata={"description": "The URL of the CVE."}, default="", compare=False + ) + + def __post_init__(self): + # make sure that the ID is a string: + if not isinstance(self.ID, str): + raise TypeError(f"CVEID ID must be a string, not {type(self.ID)}") + # and that url is a string: + if not isinstance(self.URL, str): + raise TypeError(f"CVEID URL must be a string, not {type(self.URL)}") + + def __str__(self) -> str: + return self.ID + + def __contains__(self, item): + # see if it was found in the name or vendor: + return item in self.ID + + def copy(self): + return CVEID(id=self.ID) + + def is_id(self, id: str): + return self.ID == id + + @classmethod + def from_str(cls, cve_id: str): + """ + from_str - create a CVEID object from a string. + + This function is used to create a CVEID object from a string. + + The string should be in the format "CVE-YYYY-NNNN". + + If the string is not in the correct format, a ValueError will be raised. + """ + cve_regex = r"CVE-\d{4}-\d{4,}" + if not match(cve_regex, cve_id): + raise ValueError(f"Invalid CVE ID format: {cve_id}") + + return cls(ID=cve_id) + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a CVEID object from a dictionary. + + This function is used to create a CVEID object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"ID"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/detection.py b/qualyspy/vmdr/data_classes/detection.py index 3c3a0cd..ff33fd1 100644 --- a/qualyspy/vmdr/data_classes/detection.py +++ b/qualyspy/vmdr/data_classes/detection.py @@ -1,275 +1,275 @@ -""" -detection.py - contains the Detection dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * -from datetime import datetime -from warnings import catch_warnings, simplefilter - -from bs4 import BeautifulSoup - -from .qds_factor import QDSFactor -from .qds import QDS as qds - - -@dataclass(order=True) -class Detection: - """ - Detection - represents a single QID detection on a host. - """ - - UNIQUE_VULN_ID: int = field( - metadata={"description": "The unique ID of the detection."}, compare=False - ) - QID: int = field(metadata={"description": "The QID of the detection."}) - TYPE: Literal["Confirmed", "Potential"] = field( - metadata={"description": "The type of the detection."} - ) - SEVERITY: int = field(metadata={"description": "The severity of the detection."}) - STATUS: Literal["New", "Active", "Fixed", "Re-Opened"] = field( - metadata={"description": "The status of the detection."}, compare=False - ) - SSL: Optional[bool] = field( - metadata={"description": "The SSL status of the detection."}, - default=False, - compare=False, - ) - RESULTS: Optional[str] = field( - metadata={"description": "The results of the detection."}, - default="", - compare=False, - ) - - FIRST_FOUND_DATETIME: Union[str, datetime] = field( - metadata={"description": "The date and time the detection was first found."}, - default=None, - compare=False, - ) - LAST_FOUND_DATETIME: Union[str, datetime] = field( - metadata={"description": "The date and time the detection was last found."}, - default=None, - compare=False, - ) - - QDS: Optional[qds] = field( - metadata={"description": "The Qualys Detection Score (QDS) of the detection."}, - default=None, - compare=False, - ) - - QDS_FACTORS: Optional[List[QDSFactor]] = field( - metadata={ - "description": "The Qualys Detection Score (QDS) factors of the detection." - }, - default=None, - compare=False, - ) - - TIMES_FOUND: int = field( - metadata={"description": "The number of times the detection was found."}, - default=0, - compare=False, - ) - LAST_TEST_DATETIME: Union[str, datetime] = field( - metadata={"description": "The date and time the detection was last tested."}, - default=None, - compare=False, - ) - LAST_UPDATE_DATETIME: Union[str, datetime] = field( - metadata={"description": "The date and time the detection was last updated."}, - default=None, - compare=False, - ) - IS_IGNORED: bool = field( - metadata={"description": "The ignored status of the detection."}, - default=False, - compare=False, - ) - IS_DISABLED: bool = field( - metadata={"description": "The disabled status of the detection."}, - default=False, - compare=False, - ) - LAST_PROCESSED_DATETIME: Union[str, datetime] = field( - metadata={"description": "The date and time the detection was last processed."}, - default=None, - compare=False, - ) - LAST_FIXED_DATETIME: Optional[Union[str, datetime]] = field( - metadata={"description": "The date and time the detection was last fixed."}, - default=None, - compare=False, - ) - PORT: Optional[int] = field( - metadata={"description": "The port of the detection."}, - default=None, - compare=False, - ) - PROTOCOL: Optional[str] = field( - metadata={"description": "The protocol of the detection."}, - default=None, - compare=False, - ) - FQDN: Optional[str] = field( - metadata={"description": "The fully qualified domain name of the detection."}, - default=None, - compare=False, - ) - - def __post_init__(self): - # convert the datetimes to datetime objects - DATETIME_FIELDS = [ - "FIRST_FOUND_DATETIME", - "LAST_FOUND_DATETIME", - "LAST_TEST_DATETIME", - "LAST_UPDATE_DATETIME", - "LAST_PROCESSED_DATETIME", - "LAST_FIXED_DATETIME", - ] - - HTML_FIELDS = ["RESULTS"] - - BOOL_FIELDS = ["IS_IGNORED", "IS_DISABLED", "SSL"] - - INT_FIELDS = ["UNIQUE_VULN_ID", "QID", "SEVERITY", "TIMES_FOUND", "PORT"] - - for field in DATETIME_FIELDS: - if ( - isinstance(getattr(self, field), str) - and getattr(self, field) is not None - ): - setattr(self, field, datetime.fromisoformat(getattr(self, field))) - - # clean up fields that have html tags - with catch_warnings(): - simplefilter("ignore") # ignore the warning about the html.parser - for field in HTML_FIELDS: - setattr( - self, - field, - BeautifulSoup(getattr(self, field), "html.parser").get_text(), - ) - - # convert the BOOL_FIELDS to bool - for field in BOOL_FIELDS: - if ( - not isinstance(getattr(self, field), bool) - and getattr(self, field) is not None - ): - setattr(self, field, bool(getattr(self, field))) - - # convert the INT_FIELDS to int - for field in INT_FIELDS: - if ( - not isinstance(getattr(self, field), int) - and getattr(self, field) is not None - ): - setattr(self, field, int(getattr(self, field))) - - # convert the QDS to a QDS object - if self.QDS: - self.QDS = qds(SEVERITY=self.QDS["@severity"], SCORE=int(self.QDS["#text"])) - - # convert the QDS factors to QDSFactor objects - if self.QDS_FACTORS: - # if [QDS_FACTORS][QDS_FACTOR] is a list of dictionaries, itereate through each dictionary and convert it to a QDSFactor object - # if it is just one dictionary, convert it to a QDSFactor object - if isinstance(self.QDS_FACTORS["QDS_FACTOR"], list): - self.QDS_FACTORS = [ - QDSFactor(NAME=factor["@name"], VALUE=factor["#text"]) - for factor in self.QDS_FACTORS["QDS_FACTOR"] - ] - else: - self.QDS_FACTORS = [ - QDSFactor( - NAME=self.QDS_FACTORS["QDS_FACTOR"]["@name"], - VALUE=self.QDS_FACTORS["QDS_FACTOR"]["#text"], - ) - ] - - def __str__(self): - # return str(self.UNIQUE_VULN_ID) - return str(self.QID) - - def __int__(self): - return self.QID - - def copy(self): - return Detection( - UNIQUE_VULN_ID=self.UNIQUE_VULN_ID, - QID=self.QID, - TYPE=self.TYPE, - SEVERITY=self.SEVERITY, - SSL=self.SSL, - RESULTS=self.RESULTS, - STATUS=self.STATUS, - FIRST_FOUND_DATETIME=self.FIRST_FOUND_DATETIME, - LAST_FOUND_DATETIME=self.LAST_FOUND_DATETIME, - TIMES_FOUND=self.TIMES_FOUND, - LAST_TEST_DATETIME=self.LAST_TEST_DATETIME, - LAST_UPDATE_DATETIME=self.LAST_UPDATE_DATETIME, - LAST_FIXED_DATETIME=self.LAST_FIXED_DATETIME, - IS_IGNORED=self.IS_IGNORED, - IS_DISABLED=self.IS_DISABLED, - LAST_PROCESSED_DATETIME=self.LAST_PROCESSED_DATETIME, - QDS=self.QDS, - QDS_FACTORS=self.QDS_FACTORS, - PORT=self.PORT, - PROTOCOL=self.PROTOCOL, - ) - - def valid_values(self): - # return a list of attribute names that have non-None values - return { - k: v - for k, v in self.__dict__.items() - if v is not None and v != "" and v != [] - } - - def to_dict(self): - """ - to_dict - convert the Detection object to a dictionary. - - This function is used to convert the Detection object to a dictionary. - """ - return { - "UNIQUE_VULN_ID": self.UNIQUE_VULN_ID, - "QID": self.QID, - "TYPE": self.TYPE, - "SEVERITY": self.SEVERITY, - "SSL": self.SSL, - "RESULTS": self.RESULTS, - "STATUS": self.STATUS, - "FIRST_FOUND_DATETIME": self.FIRST_FOUND_DATETIME, - "LAST_FOUND_DATETIME": self.LAST_FOUND_DATETIME, - "TIMES_FOUND": self.TIMES_FOUND, - "LAST_TEST_DATETIME": self.LAST_TEST_DATETIME, - "LAST_UPDATE_DATETIME": self.LAST_UPDATE_DATETIME, - "IS_IGNORED": self.IS_IGNORED, - "IS_DISABLED": self.IS_DISABLED, - "LAST_PROCESSED_DATETIME": self.LAST_PROCESSED_DATETIME, - "LAST_FIXED_DATETIME": self.LAST_FIXED_DATETIME, - "QDS": self.QDS, - "QDS_FACTORS": self.QDS_FACTORS, - "PORT": self.PORT, - "PROTOCOL": self.PROTOCOL, - "FQDN": self.FQDN, - } - - @classmethod - def from_dict(cls, data: Union[dict, list]): - """ - from_dict - create a Software object from a dictionary. - - This function is used to create a Software object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"QID", "SEVERITY", "STATUS", "TYPE"} - - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +detection.py - contains the Detection dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * +from datetime import datetime +from warnings import catch_warnings, simplefilter + +from bs4 import BeautifulSoup + +from .qds_factor import QDSFactor +from .qds import QDS as qds + + +@dataclass(order=True) +class Detection: + """ + Detection - represents a single QID detection on a host. + """ + + UNIQUE_VULN_ID: int = field( + metadata={"description": "The unique ID of the detection."}, compare=False + ) + QID: int = field(metadata={"description": "The QID of the detection."}) + TYPE: Literal["Confirmed", "Potential"] = field( + metadata={"description": "The type of the detection."} + ) + SEVERITY: int = field(metadata={"description": "The severity of the detection."}) + STATUS: Literal["New", "Active", "Fixed", "Re-Opened"] = field( + metadata={"description": "The status of the detection."}, compare=False + ) + SSL: Optional[bool] = field( + metadata={"description": "The SSL status of the detection."}, + default=False, + compare=False, + ) + RESULTS: Optional[str] = field( + metadata={"description": "The results of the detection."}, + default="", + compare=False, + ) + + FIRST_FOUND_DATETIME: Union[str, datetime] = field( + metadata={"description": "The date and time the detection was first found."}, + default=None, + compare=False, + ) + LAST_FOUND_DATETIME: Union[str, datetime] = field( + metadata={"description": "The date and time the detection was last found."}, + default=None, + compare=False, + ) + + QDS: Optional[qds] = field( + metadata={"description": "The Qualys Detection Score (QDS) of the detection."}, + default=None, + compare=False, + ) + + QDS_FACTORS: Optional[List[QDSFactor]] = field( + metadata={ + "description": "The Qualys Detection Score (QDS) factors of the detection." + }, + default=None, + compare=False, + ) + + TIMES_FOUND: int = field( + metadata={"description": "The number of times the detection was found."}, + default=0, + compare=False, + ) + LAST_TEST_DATETIME: Union[str, datetime] = field( + metadata={"description": "The date and time the detection was last tested."}, + default=None, + compare=False, + ) + LAST_UPDATE_DATETIME: Union[str, datetime] = field( + metadata={"description": "The date and time the detection was last updated."}, + default=None, + compare=False, + ) + IS_IGNORED: bool = field( + metadata={"description": "The ignored status of the detection."}, + default=False, + compare=False, + ) + IS_DISABLED: bool = field( + metadata={"description": "The disabled status of the detection."}, + default=False, + compare=False, + ) + LAST_PROCESSED_DATETIME: Union[str, datetime] = field( + metadata={"description": "The date and time the detection was last processed."}, + default=None, + compare=False, + ) + LAST_FIXED_DATETIME: Optional[Union[str, datetime]] = field( + metadata={"description": "The date and time the detection was last fixed."}, + default=None, + compare=False, + ) + PORT: Optional[int] = field( + metadata={"description": "The port of the detection."}, + default=None, + compare=False, + ) + PROTOCOL: Optional[str] = field( + metadata={"description": "The protocol of the detection."}, + default=None, + compare=False, + ) + FQDN: Optional[str] = field( + metadata={"description": "The fully qualified domain name of the detection."}, + default=None, + compare=False, + ) + + def __post_init__(self): + # convert the datetimes to datetime objects + DATETIME_FIELDS = [ + "FIRST_FOUND_DATETIME", + "LAST_FOUND_DATETIME", + "LAST_TEST_DATETIME", + "LAST_UPDATE_DATETIME", + "LAST_PROCESSED_DATETIME", + "LAST_FIXED_DATETIME", + ] + + HTML_FIELDS = ["RESULTS"] + + BOOL_FIELDS = ["IS_IGNORED", "IS_DISABLED", "SSL"] + + INT_FIELDS = ["UNIQUE_VULN_ID", "QID", "SEVERITY", "TIMES_FOUND", "PORT"] + + for field in DATETIME_FIELDS: + if ( + isinstance(getattr(self, field), str) + and getattr(self, field) is not None + ): + setattr(self, field, datetime.fromisoformat(getattr(self, field))) + + # clean up fields that have html tags + with catch_warnings(): + simplefilter("ignore") # ignore the warning about the html.parser + for field in HTML_FIELDS: + setattr( + self, + field, + BeautifulSoup(getattr(self, field), "html.parser").get_text(), + ) + + # convert the BOOL_FIELDS to bool + for field in BOOL_FIELDS: + if ( + not isinstance(getattr(self, field), bool) + and getattr(self, field) is not None + ): + setattr(self, field, bool(getattr(self, field))) + + # convert the INT_FIELDS to int + for field in INT_FIELDS: + if ( + not isinstance(getattr(self, field), int) + and getattr(self, field) is not None + ): + setattr(self, field, int(getattr(self, field))) + + # convert the QDS to a QDS object + if self.QDS: + self.QDS = qds(SEVERITY=self.QDS["@severity"], SCORE=int(self.QDS["#text"])) + + # convert the QDS factors to QDSFactor objects + if self.QDS_FACTORS: + # if [QDS_FACTORS][QDS_FACTOR] is a list of dictionaries, itereate through each dictionary and convert it to a QDSFactor object + # if it is just one dictionary, convert it to a QDSFactor object + if isinstance(self.QDS_FACTORS["QDS_FACTOR"], list): + self.QDS_FACTORS = [ + QDSFactor(NAME=factor["@name"], VALUE=factor["#text"]) + for factor in self.QDS_FACTORS["QDS_FACTOR"] + ] + else: + self.QDS_FACTORS = [ + QDSFactor( + NAME=self.QDS_FACTORS["QDS_FACTOR"]["@name"], + VALUE=self.QDS_FACTORS["QDS_FACTOR"]["#text"], + ) + ] + + def __str__(self): + # return str(self.UNIQUE_VULN_ID) + return str(self.QID) + + def __int__(self): + return self.QID + + def copy(self): + return Detection( + UNIQUE_VULN_ID=self.UNIQUE_VULN_ID, + QID=self.QID, + TYPE=self.TYPE, + SEVERITY=self.SEVERITY, + SSL=self.SSL, + RESULTS=self.RESULTS, + STATUS=self.STATUS, + FIRST_FOUND_DATETIME=self.FIRST_FOUND_DATETIME, + LAST_FOUND_DATETIME=self.LAST_FOUND_DATETIME, + TIMES_FOUND=self.TIMES_FOUND, + LAST_TEST_DATETIME=self.LAST_TEST_DATETIME, + LAST_UPDATE_DATETIME=self.LAST_UPDATE_DATETIME, + LAST_FIXED_DATETIME=self.LAST_FIXED_DATETIME, + IS_IGNORED=self.IS_IGNORED, + IS_DISABLED=self.IS_DISABLED, + LAST_PROCESSED_DATETIME=self.LAST_PROCESSED_DATETIME, + QDS=self.QDS, + QDS_FACTORS=self.QDS_FACTORS, + PORT=self.PORT, + PROTOCOL=self.PROTOCOL, + ) + + def valid_values(self): + # return a list of attribute names that have non-None values + return { + k: v + for k, v in self.__dict__.items() + if v is not None and v != "" and v != [] + } + + def to_dict(self): + """ + to_dict - convert the Detection object to a dictionary. + + This function is used to convert the Detection object to a dictionary. + """ + return { + "UNIQUE_VULN_ID": self.UNIQUE_VULN_ID, + "QID": self.QID, + "TYPE": self.TYPE, + "SEVERITY": self.SEVERITY, + "SSL": self.SSL, + "RESULTS": self.RESULTS, + "STATUS": self.STATUS, + "FIRST_FOUND_DATETIME": self.FIRST_FOUND_DATETIME, + "LAST_FOUND_DATETIME": self.LAST_FOUND_DATETIME, + "TIMES_FOUND": self.TIMES_FOUND, + "LAST_TEST_DATETIME": self.LAST_TEST_DATETIME, + "LAST_UPDATE_DATETIME": self.LAST_UPDATE_DATETIME, + "IS_IGNORED": self.IS_IGNORED, + "IS_DISABLED": self.IS_DISABLED, + "LAST_PROCESSED_DATETIME": self.LAST_PROCESSED_DATETIME, + "LAST_FIXED_DATETIME": self.LAST_FIXED_DATETIME, + "QDS": self.QDS, + "QDS_FACTORS": self.QDS_FACTORS, + "PORT": self.PORT, + "PROTOCOL": self.PROTOCOL, + "FQDN": self.FQDN, + } + + @classmethod + def from_dict(cls, data: Union[dict, list]): + """ + from_dict - create a Software object from a dictionary. + + This function is used to create a Software object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"QID", "SEVERITY", "STATUS", "TYPE"} + + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/hosts.py b/qualyspy/vmdr/data_classes/hosts.py index 5c42748..11183aa 100644 --- a/qualyspy/vmdr/data_classes/hosts.py +++ b/qualyspy/vmdr/data_classes/hosts.py @@ -1,554 +1,554 @@ -""" -hosts.py - contains the VMDRHosts class for the Qualyspy package. -""" - -from dataclasses import dataclass, field -from typing import * -from datetime import datetime - -from ipaddress import IPv4Address, IPv6Address - -from .lists import BaseList -from .tag import Tag, CloudTag -from .detection import Detection - - -@dataclass(order=True) -class VMDRHost: - """ - Host - represents a Qualys VMDR host record. - """ - - ID: Union[str, int] = field( - default=None, metadata={"description": "The HOST ID of the host."} - ) # this is the host ID, not the asset ID. add to post init to cast to int - ASSET_ID: Union[str, int] = field( - default=None, metadata={"description": "The asset ID of the host."} - ) # add to post init to cast to int - IP: Union[str, IPv4Address] = field( - default=None, - metadata={"description": "The IP address of the host."}, - compare=False, - ) - IPV6: Union[str, IPv6Address] = field( - default=None, - metadata={"description": "The IPv6 address of the host."}, - compare=False, - ) - TRACKING_METHOD: str = field( - default=None, - metadata={"description": "The tracking method of the host."}, - compare=False, - ) - DNS: str = field( - default=None, - metadata={"description": "The DNS name of the host."}, - compare=False, - ) - - # DNS_DATA is a dictionary containing the keys HOSTNAME, DOMAIN, and FQDN. We will make these keys attributes of the class. - DNS_DATA: Dict[str, str] = field( - default=None, - metadata={"description": "The DNS data of the host."}, - compare=False, - ) - - NETBIOS: str = field( - default=None, - metadata={"description": "The NetBIOS name of the host."}, - compare=False, - ) - OS: str = field( - default=None, - metadata={"description": "The operating system of the host."}, - compare=False, - ) - QG_HOSTID: str = field( - default=None, metadata={"description": "The QualysGuard host ID of the host."} - ) - LAST_BOOT: Union[str, datetime] = field( - default=None, - metadata={"description": "The last boot time of the host."}, - compare=False, - ) # add to post init to cast to datetime - - LAST_SCAN_DATETIME: Union[str, datetime] = field( - default=None, - metadata={"description": "The last scan date of the host."}, - compare=False, - ) # add to post init to cast to datetime - - SERIAL_NUMBER: str = field( - default=None, - metadata={"description": "The serial number of the host."}, - compare=False, - ) - HARDWARE_UUID: str = field( - default=None, metadata={"description": "The hardware UUID of the host."} - ) - FIRST_FOUND_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The first found date of the host."}, - compare=False, - ) # add to post init to cast to datetime - LAST_ACTIVITY: Union[str, datetime] = field( - default=None, - metadata={"description": "The last activity date of the host."}, - compare=False, - ) - LAST_ACTIVITY_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The last activity date of the host."}, - compare=False, - ) # add to post init to cast to datetime - AGENT_STATUS: str = field( - default=None, - metadata={"description": "The agent status of the host."}, - compare=False, - ) - CLOUD_AGENT_RUNNING_ON: str = field( - default=None, - metadata={"description": "The cloud agent running on of the host."}, - compare=False, - ) - TAGS: Union[dict, BaseList] = field( - default=None, metadata={"description": "The tags of the host."}, compare=False - ) # add to post init to convert to BaseList (look at GAV lists for how to do this), as well as make a Tag class - LAST_VULN_SCAN_DATETIME: Union[str, datetime] = field( - default=None, - metadata={"description": "The last vulnerability scan date of the host."}, - compare=False, - ) # add to post init to cast to datetime - LAST_VULN_SCAN_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The last vulnerability scan date of the host."}, - compare=False, - ) # add to post init to cast to datetime - LAST_VM_SCANNED_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The last VM scan date of the host."}, - compare=False, - ) - LAST_VM_AUTH_SCANNED_DURATION: Union[str, int] = field( - default=None, - metadata={"description": "The last VM scanned duration of the host."}, - compare=False, - ) # add to post init to cast to int - LAST_VM_SCANNED_DURATION: Union[str, int] = field( - default=None, - metadata={"description": "The last VM scanned duration of the host."}, - compare=False, - ) # add to post init to cast to int - LAST_VM_AUTH_SCANNED_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The last VM auth scanned date of the host."}, - compare=False, - ) # add to post init to cast to datetime - LAST_COMPLIANCE_SCAN_DATETIME: Union[str, datetime] = field( - default=None, - metadata={"description": "The last compliance scan date of the host."}, - compare=False, - ) # add to post init to cast to datetime - - LAST_PC_SCANNED_DATE: Union[str, datetime] = field( - default=None, - metadata={"description": "The last PC scanned date of the host."}, - compare=False, - ) # add to post init to cast to datetime - - ASSET_GROUP_IDS: List[str] = field( - default=None, - metadata={"description": "The asset group IDs of the host."}, - compare=False, - ) - USER_DEF: dict = field( - default=None, - metadata={"description": "The user def of the host."}, - compare=False, - ) - OWNER: str = field( - default=None, metadata={"description": "The owner of the host."}, compare=False - ) - - # CLOUD HOST FIELDS: - CLOUD_PROVIDER: str = field( - default=None, - metadata={"description": "The cloud provider of the host."}, - compare=False, - ) - CLOUD_SERVICE: str = field( - default=None, - metadata={"description": "The cloud service of the host."}, - compare=False, - ) - CLOUD_RESOURCE_ID: str = field( - default=None, - metadata={"description": "The cloud resource ID of the host."}, - compare=False, - ) - EC2_INSTANCE_ID: str = field( - default=None, - metadata={"description": "The EC2 instance ID of the host."}, - compare=False, - ) - CLOUD_PROVIDER_TAGS: Union[dict, BaseList] = field( - default=None, - metadata={"description": "The cloud provider tags of the host."}, - compare=False, - ) - - # this METADATA field contains a lot of nested data, so we will need to parse it out in post init. The immediate key underneath is the cloud service, i.e. EC2, AZURE, etc. - # and gets parsed in post init. - METADATA: dict = field( - default=None, - metadata={"description": "The metadata of the host."}, - compare=False, - ) - - DETECTION_LIST: Optional[Union[list[Detection], BaseList[Detection]]] = field( - default_factory=BaseList, - metadata={"description": "The detection list of the host."}, - compare=False, - ) - - ASSET_RISK_SCORE: Optional[Union[str, int]] = field( - default=None, - metadata={"description": "The asset risk score of the host."}, - compare=False, - ) - - TRURISK_SCORE: Optional[Union[str, int]] = field( - default=None, - metadata={"description": "The TruRisk score of the host."}, - compare=False, - ) - - TRURISK_SCORE_FACTORS: Optional[dict] = field( - default=None, - metadata={"description": "The TruRisk score factors of the host."}, - compare=False, - ) - - ASSET_CRITICALITY_SCORE: Optional[Union[str, int]] = field( - default=None, - metadata={"description": "The asset criticality score of the host."}, - compare=False, - ) - - def __post_init__(self): - """ - Pull up nested dict values as attributes, convert IPs, - put tags in a BaseList and convert strings to datetime objects. - """ - DNS_DATA_FIELDS = ["HOSTNAME", "DOMAIN", "FQDN"] - DATETIME_FIELDS = [ - "LAST_BOOT", - "FIRST_FOUND_DATE", - "LAST_ACTIVITY_DATE", - "LAST_SCAN_DATETIME", - "LAST_VULN_SCAN_DATETIME", - "LAST_VM_SCANNED_DATE", - "LAST_VM_AUTH_SCANNED_DATE", - "LAST_VM_AUTH_SCANNED_DATE", - "LAST_COMPLIANCE_SCAN_DATETIME", - "LAST_VULN_SCAN_DATE", - "LAST_ACTIVITY", - "LAST_PC_SCANNED_DATE", - ] - INT_FIELDS = [ - "ID", - "ASSET_ID", - "LAST_VM_SCANNED_DURATION", - "ASSET_RISK_SCORE", - "TRURISK_SCORE", - "ASSET_CRITICALITY_SCORE", - ] # (cloud) ACCOUNT_ID cannot go here as it is not initialized yet - - if self.DNS_DATA: - for field in DNS_DATA_FIELDS: - setattr(self, field, self.DNS_DATA.get(field)) - - if self.IP and not isinstance(self.IP, IPv4Address): - self.IP = IPv4Address(self.IP) - - if self.IPV6 and not isinstance(self.IPV6, IPv6Address): - self.IPV6 = IPv6Address(self.IPV6) - - for DATE_FIELD in DATETIME_FIELDS: - if getattr(self, DATE_FIELD) and not isinstance( - getattr(self, DATE_FIELD), datetime - ): - setattr( - self, DATE_FIELD, datetime.fromisoformat(getattr(self, DATE_FIELD)) - ) - - for INT_FIELD in INT_FIELDS: - if getattr(self, INT_FIELD) and not isinstance( - getattr(self, INT_FIELD), int - ): - setattr(self, INT_FIELD, int(getattr(self, INT_FIELD))) - - if self.TAGS: - # if 'TAG' key's value is a list, it is a list of tag dicts. if it is a single tag dict, it is just a single tag. - if isinstance(self.TAGS["TAG"], list): - self.TAGS = BaseList([Tag.from_dict(tag) for tag in self.TAGS["TAG"]]) - else: # if it is a single tag dict: - self.TAGS = BaseList([Tag.from_dict(self.TAGS["TAG"])]) - - if self.CLOUD_PROVIDER_TAGS: - # if 'CLOUD_TAG' key's value is a list, it is a list of tag dicts. if it is a single tag dict, it is just a single tag. - if isinstance(self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"], list): - self.CLOUD_PROVIDER_TAGS = BaseList( - [ - CloudTag.from_dict(tag) - for tag in self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"] - ] - ) - else: # if it is a single tag dict: - self.CLOUD_PROVIDER_TAGS = BaseList( - [CloudTag.from_dict(self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"])] - ) - - # CLOUD SPECIFIC FIELDS: - if self.METADATA: - match self.CLOUD_PROVIDER: - case "AWS": - key_selector = "EC2" - case "Azure": - key_selector = "AZURE" - case "GCP": - key_selector = "GCP" - case _: - raise ValueError( - f"Cloud provider {self.CLOUD_PROVIDER} is not supported." - ) - - # for each tuple, [0] is the dataclass attribute name, [1] is how it is represented in the metadata dict. - VALID_EC2_KEYS = [ - ("GROUP_NAME", "groupName"), - ("INSTANCE_STATE", "instanceState"), - ("INSTANCE_TYPE", "latest/meta-data/instance-type"), - ("IS_SPOT_INSTANCE", "isSpotInstance"), - ( - "ARCHITECTURE", - "latest/dynamic/instance-identity/document/architecture", - ), - ("IMAGE_ID", "latest/dynamic/instance-identity/document/imageId"), - ("REGION", "latest/dynamic/instance-identity/document/region"), - ("AMI_ID", "latest/meta-data/ami-id"), - ("PUBLIC_HOSTNAME", "latest/meta-data/public-hostname"), - ("PUBLIC_IPV4", "latest/meta-data/public-ipv4"), - ("ACCOUNT_ID", "asset.aws.ec2.accountId"), - ] - VALID_AZURE_KEYS = [ - ("PUBLIC_IPV4", "latest/meta-data/public-ipv4"), - ("INSTANCE_STATE", "state"), - ("GROUP_NAME", "resourceGroupName"), - ("INSTANCE_TYPE", "vmSize"), - ("REGION", "location"), - ("ACCOUNT_ID", "subscriptionId"), - ] - VALID_GCP_KEYS = ... - # map key_selector to the valid keys list: - VALID_KEYS = { - "EC2": VALID_EC2_KEYS, - "AZURE": VALID_AZURE_KEYS, - # "GCP": VALID_GCP_KEYS #not implemented as i have no access to a GCP environment. - } - - for key in VALID_KEYS[key_selector]: - # check for if self.METADATA[key_selector]['ATTRIBUTE'] is a list of dicts. if not, it is just a single dict. - if isinstance(self.METADATA[key_selector]["ATTRIBUTE"], list): - for item in self.METADATA[key_selector]["ATTRIBUTE"]: - if item["NAME"] == key[1]: - setattr(self, f"CLOUD_{key[0]}", item["VALUE"]) - else: - if self.METADATA[key_selector]["ATTRIBUTE"]["NAME"] == key: - setattr( - self, - f"CLOUD_{key[0]}", - self.METADATA[key_selector]["ATTRIBUTE"]["VALUE"], - ) - - # check for a detections list and convert it to a BaseList of Detection objects (used in hld): - if self.DETECTION_LIST: - if isinstance(self.DETECTION_LIST["DETECTION"], list): - self.DETECTION_LIST = BaseList( - [ - Detection.from_dict(detection) - for detection in self.DETECTION_LIST["DETECTION"] - ] - ) - else: - self.DETECTION_LIST = BaseList( - [Detection.from_dict(self.DETECTION_LIST["DETECTION"])] - ) - - def __str__(self) -> str: - """ - String representation of the host object. - """ - if self.ASSET_ID: - return f"Host({self.ASSET_ID})" - elif self.ID: - return f"Host({self.ID})" - else: - # fall back to QG_HostID: - return f"Host({self.QG_HOSTID})" - - def __int__(self) -> int: - if self.ASSET_ID: - return self.ASSET_ID - elif self.ID: - return self.ID - else: - raise ValueError("Host object does not have an asset ID or host ID.") - - def __repr__(self) -> str: - """ - Breaking the unwritten rule of having repr be something - that can create the object for terminal space's sake. - """ - if self.ASSET_ID: - return f"Host({self.ASSET_ID})" - elif self.ID: - return f"Host({self.ID})" - else: - # fall back to QG_HostID: - return f"Host({self.QG_HOSTID})" - - def __iter__(self): - """ - Allows for iteration over the host object. - """ - for key, value in self.__dict__.items(): - yield key, value - - def has_agent(self) -> bool: - """ - Check if the host has an agent. - """ - return self.QG_HOSTID is not None - - def is_cloud_host(self) -> bool: - """ - Check if the host is a cloud host. - """ - return self.CLOUD_PROVIDER is not None - - def is_aws(self) -> bool: - """ - Check if the host is an AWS host. - """ - return self.CLOUD_PROVIDER == "AWS" - - def is_azure(self) -> bool: - """ - Check if the host is an Azure host. - """ - return self.CLOUD_PROVIDER == "Azure" - - def copy(self) -> "VMDRHost": - """ - Create a copy of the host object. - """ - return VMDRHost(**self.__dict__) - - def valid_values(self) -> list: - """ - Return a list of keys that have values. - """ - return { - k: v - for k, v in self.__dict__.items() - if v is not None - and v != "" - and v != [] - and (isinstance(v, BaseList) and v != BaseList()) - } - - @classmethod - def from_dict(cls, data: dict) -> "VMDRHost": - """ - Create a VMDRHost object from a dictionary. - """ - return cls(**data) - - -@dataclass -class VMDRID: - """ - ID - represents a Qualys GAV ID record. - - This class is used to represent a Qualys GAV ID record, either a host ID or an asset ID. - This class is only ever used if details=None in a get_host_list or get_host_list_detections API call. - """ - - ID: Union[str, int] = field( - metadata={"description": "The asset ID of the host."} - ) # add to post init to cast to int - TYPE: Literal["asset", "host"] = field( - metadata={"description": "The type of ID. Valid values are 'asset' or 'host'."}, - compare=False, - ) # field is required! - - def __post_init__(self): - """ - Cast the asset ID and host ID to integers if they are not None. - """ - - if not self.ID: - raise ValueError("ID attribute cannot be None.") - - if self.TYPE == "asset" and self.ID: - self.ID = int(self.ID) - elif self.TYPE == "host" and self.ID: - self.ID = int(self.ID) - else: - raise ValueError( - f"TYPE attribute must be 'asset' or 'host, not {self.TYPE}'" - ) - - def __str__(self) -> str: - return str(self.ID) - - def __int__(self) -> int: - return self.ID - - def __repr__(self) -> str: - if self.TYPE == "asset": - return f"VMDRID({self.ID}, type='asset')" - else: - return f"VMDRID({self.ID}, type='host')" - - def __iter__(self): - """ - Iterate over the fields of the host object. - """ - for key, value in self.__dict__.items(): - yield key, value - - def values(self): - """ - Return the values of the object. - """ - return self.__dict__.values() - - def keys(self): - """ - Return the keys of the object. - """ - return self.__dict__.keys() - - @classmethod - def from_dict(cls, data: dict) -> "VMDRID": - """ - Create a VMDRID object from a dictionary. - """ - required_keys = {"ID", "TYPE"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +hosts.py - contains the VMDRHosts class for the Qualyspy package. +""" + +from dataclasses import dataclass, field +from typing import * +from datetime import datetime + +from ipaddress import IPv4Address, IPv6Address + +from .lists import BaseList +from .tag import Tag, CloudTag +from .detection import Detection + + +@dataclass(order=True) +class VMDRHost: + """ + Host - represents a Qualys VMDR host record. + """ + + ID: Union[str, int] = field( + default=None, metadata={"description": "The HOST ID of the host."} + ) # this is the host ID, not the asset ID. add to post init to cast to int + ASSET_ID: Union[str, int] = field( + default=None, metadata={"description": "The asset ID of the host."} + ) # add to post init to cast to int + IP: Union[str, IPv4Address] = field( + default=None, + metadata={"description": "The IP address of the host."}, + compare=False, + ) + IPV6: Union[str, IPv6Address] = field( + default=None, + metadata={"description": "The IPv6 address of the host."}, + compare=False, + ) + TRACKING_METHOD: str = field( + default=None, + metadata={"description": "The tracking method of the host."}, + compare=False, + ) + DNS: str = field( + default=None, + metadata={"description": "The DNS name of the host."}, + compare=False, + ) + + # DNS_DATA is a dictionary containing the keys HOSTNAME, DOMAIN, and FQDN. We will make these keys attributes of the class. + DNS_DATA: Dict[str, str] = field( + default=None, + metadata={"description": "The DNS data of the host."}, + compare=False, + ) + + NETBIOS: str = field( + default=None, + metadata={"description": "The NetBIOS name of the host."}, + compare=False, + ) + OS: str = field( + default=None, + metadata={"description": "The operating system of the host."}, + compare=False, + ) + QG_HOSTID: str = field( + default=None, metadata={"description": "The QualysGuard host ID of the host."} + ) + LAST_BOOT: Union[str, datetime] = field( + default=None, + metadata={"description": "The last boot time of the host."}, + compare=False, + ) # add to post init to cast to datetime + + LAST_SCAN_DATETIME: Union[str, datetime] = field( + default=None, + metadata={"description": "The last scan date of the host."}, + compare=False, + ) # add to post init to cast to datetime + + SERIAL_NUMBER: str = field( + default=None, + metadata={"description": "The serial number of the host."}, + compare=False, + ) + HARDWARE_UUID: str = field( + default=None, metadata={"description": "The hardware UUID of the host."} + ) + FIRST_FOUND_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The first found date of the host."}, + compare=False, + ) # add to post init to cast to datetime + LAST_ACTIVITY: Union[str, datetime] = field( + default=None, + metadata={"description": "The last activity date of the host."}, + compare=False, + ) + LAST_ACTIVITY_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The last activity date of the host."}, + compare=False, + ) # add to post init to cast to datetime + AGENT_STATUS: str = field( + default=None, + metadata={"description": "The agent status of the host."}, + compare=False, + ) + CLOUD_AGENT_RUNNING_ON: str = field( + default=None, + metadata={"description": "The cloud agent running on of the host."}, + compare=False, + ) + TAGS: Union[dict, BaseList] = field( + default=None, metadata={"description": "The tags of the host."}, compare=False + ) # add to post init to convert to BaseList (look at GAV lists for how to do this), as well as make a Tag class + LAST_VULN_SCAN_DATETIME: Union[str, datetime] = field( + default=None, + metadata={"description": "The last vulnerability scan date of the host."}, + compare=False, + ) # add to post init to cast to datetime + LAST_VULN_SCAN_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The last vulnerability scan date of the host."}, + compare=False, + ) # add to post init to cast to datetime + LAST_VM_SCANNED_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The last VM scan date of the host."}, + compare=False, + ) + LAST_VM_AUTH_SCANNED_DURATION: Union[str, int] = field( + default=None, + metadata={"description": "The last VM scanned duration of the host."}, + compare=False, + ) # add to post init to cast to int + LAST_VM_SCANNED_DURATION: Union[str, int] = field( + default=None, + metadata={"description": "The last VM scanned duration of the host."}, + compare=False, + ) # add to post init to cast to int + LAST_VM_AUTH_SCANNED_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The last VM auth scanned date of the host."}, + compare=False, + ) # add to post init to cast to datetime + LAST_COMPLIANCE_SCAN_DATETIME: Union[str, datetime] = field( + default=None, + metadata={"description": "The last compliance scan date of the host."}, + compare=False, + ) # add to post init to cast to datetime + + LAST_PC_SCANNED_DATE: Union[str, datetime] = field( + default=None, + metadata={"description": "The last PC scanned date of the host."}, + compare=False, + ) # add to post init to cast to datetime + + ASSET_GROUP_IDS: List[str] = field( + default=None, + metadata={"description": "The asset group IDs of the host."}, + compare=False, + ) + USER_DEF: dict = field( + default=None, + metadata={"description": "The user def of the host."}, + compare=False, + ) + OWNER: str = field( + default=None, metadata={"description": "The owner of the host."}, compare=False + ) + + # CLOUD HOST FIELDS: + CLOUD_PROVIDER: str = field( + default=None, + metadata={"description": "The cloud provider of the host."}, + compare=False, + ) + CLOUD_SERVICE: str = field( + default=None, + metadata={"description": "The cloud service of the host."}, + compare=False, + ) + CLOUD_RESOURCE_ID: str = field( + default=None, + metadata={"description": "The cloud resource ID of the host."}, + compare=False, + ) + EC2_INSTANCE_ID: str = field( + default=None, + metadata={"description": "The EC2 instance ID of the host."}, + compare=False, + ) + CLOUD_PROVIDER_TAGS: Union[dict, BaseList] = field( + default=None, + metadata={"description": "The cloud provider tags of the host."}, + compare=False, + ) + + # this METADATA field contains a lot of nested data, so we will need to parse it out in post init. The immediate key underneath is the cloud service, i.e. EC2, AZURE, etc. + # and gets parsed in post init. + METADATA: dict = field( + default=None, + metadata={"description": "The metadata of the host."}, + compare=False, + ) + + DETECTION_LIST: Optional[Union[list[Detection], BaseList[Detection]]] = field( + default_factory=BaseList, + metadata={"description": "The detection list of the host."}, + compare=False, + ) + + ASSET_RISK_SCORE: Optional[Union[str, int]] = field( + default=None, + metadata={"description": "The asset risk score of the host."}, + compare=False, + ) + + TRURISK_SCORE: Optional[Union[str, int]] = field( + default=None, + metadata={"description": "The TruRisk score of the host."}, + compare=False, + ) + + TRURISK_SCORE_FACTORS: Optional[dict] = field( + default=None, + metadata={"description": "The TruRisk score factors of the host."}, + compare=False, + ) + + ASSET_CRITICALITY_SCORE: Optional[Union[str, int]] = field( + default=None, + metadata={"description": "The asset criticality score of the host."}, + compare=False, + ) + + def __post_init__(self): + """ + Pull up nested dict values as attributes, convert IPs, + put tags in a BaseList and convert strings to datetime objects. + """ + DNS_DATA_FIELDS = ["HOSTNAME", "DOMAIN", "FQDN"] + DATETIME_FIELDS = [ + "LAST_BOOT", + "FIRST_FOUND_DATE", + "LAST_ACTIVITY_DATE", + "LAST_SCAN_DATETIME", + "LAST_VULN_SCAN_DATETIME", + "LAST_VM_SCANNED_DATE", + "LAST_VM_AUTH_SCANNED_DATE", + "LAST_VM_AUTH_SCANNED_DATE", + "LAST_COMPLIANCE_SCAN_DATETIME", + "LAST_VULN_SCAN_DATE", + "LAST_ACTIVITY", + "LAST_PC_SCANNED_DATE", + ] + INT_FIELDS = [ + "ID", + "ASSET_ID", + "LAST_VM_SCANNED_DURATION", + "ASSET_RISK_SCORE", + "TRURISK_SCORE", + "ASSET_CRITICALITY_SCORE", + ] # (cloud) ACCOUNT_ID cannot go here as it is not initialized yet + + if self.DNS_DATA: + for field in DNS_DATA_FIELDS: + setattr(self, field, self.DNS_DATA.get(field)) + + if self.IP and not isinstance(self.IP, IPv4Address): + self.IP = IPv4Address(self.IP) + + if self.IPV6 and not isinstance(self.IPV6, IPv6Address): + self.IPV6 = IPv6Address(self.IPV6) + + for DATE_FIELD in DATETIME_FIELDS: + if getattr(self, DATE_FIELD) and not isinstance( + getattr(self, DATE_FIELD), datetime + ): + setattr( + self, DATE_FIELD, datetime.fromisoformat(getattr(self, DATE_FIELD)) + ) + + for INT_FIELD in INT_FIELDS: + if getattr(self, INT_FIELD) and not isinstance( + getattr(self, INT_FIELD), int + ): + setattr(self, INT_FIELD, int(getattr(self, INT_FIELD))) + + if self.TAGS: + # if 'TAG' key's value is a list, it is a list of tag dicts. if it is a single tag dict, it is just a single tag. + if isinstance(self.TAGS["TAG"], list): + self.TAGS = BaseList([Tag.from_dict(tag) for tag in self.TAGS["TAG"]]) + else: # if it is a single tag dict: + self.TAGS = BaseList([Tag.from_dict(self.TAGS["TAG"])]) + + if self.CLOUD_PROVIDER_TAGS: + # if 'CLOUD_TAG' key's value is a list, it is a list of tag dicts. if it is a single tag dict, it is just a single tag. + if isinstance(self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"], list): + self.CLOUD_PROVIDER_TAGS = BaseList( + [ + CloudTag.from_dict(tag) + for tag in self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"] + ] + ) + else: # if it is a single tag dict: + self.CLOUD_PROVIDER_TAGS = BaseList( + [CloudTag.from_dict(self.CLOUD_PROVIDER_TAGS["CLOUD_TAG"])] + ) + + # CLOUD SPECIFIC FIELDS: + if self.METADATA: + match self.CLOUD_PROVIDER: + case "AWS": + key_selector = "EC2" + case "Azure": + key_selector = "AZURE" + case "GCP": + key_selector = "GCP" + case _: + raise ValueError( + f"Cloud provider {self.CLOUD_PROVIDER} is not supported." + ) + + # for each tuple, [0] is the dataclass attribute name, [1] is how it is represented in the metadata dict. + VALID_EC2_KEYS = [ + ("GROUP_NAME", "groupName"), + ("INSTANCE_STATE", "instanceState"), + ("INSTANCE_TYPE", "latest/meta-data/instance-type"), + ("IS_SPOT_INSTANCE", "isSpotInstance"), + ( + "ARCHITECTURE", + "latest/dynamic/instance-identity/document/architecture", + ), + ("IMAGE_ID", "latest/dynamic/instance-identity/document/imageId"), + ("REGION", "latest/dynamic/instance-identity/document/region"), + ("AMI_ID", "latest/meta-data/ami-id"), + ("PUBLIC_HOSTNAME", "latest/meta-data/public-hostname"), + ("PUBLIC_IPV4", "latest/meta-data/public-ipv4"), + ("ACCOUNT_ID", "asset.aws.ec2.accountId"), + ] + VALID_AZURE_KEYS = [ + ("PUBLIC_IPV4", "latest/meta-data/public-ipv4"), + ("INSTANCE_STATE", "state"), + ("GROUP_NAME", "resourceGroupName"), + ("INSTANCE_TYPE", "vmSize"), + ("REGION", "location"), + ("ACCOUNT_ID", "subscriptionId"), + ] + VALID_GCP_KEYS = ... + # map key_selector to the valid keys list: + VALID_KEYS = { + "EC2": VALID_EC2_KEYS, + "AZURE": VALID_AZURE_KEYS, + # "GCP": VALID_GCP_KEYS #not implemented as i have no access to a GCP environment. + } + + for key in VALID_KEYS[key_selector]: + # check for if self.METADATA[key_selector]['ATTRIBUTE'] is a list of dicts. if not, it is just a single dict. + if isinstance(self.METADATA[key_selector]["ATTRIBUTE"], list): + for item in self.METADATA[key_selector]["ATTRIBUTE"]: + if item["NAME"] == key[1]: + setattr(self, f"CLOUD_{key[0]}", item["VALUE"]) + else: + if self.METADATA[key_selector]["ATTRIBUTE"]["NAME"] == key: + setattr( + self, + f"CLOUD_{key[0]}", + self.METADATA[key_selector]["ATTRIBUTE"]["VALUE"], + ) + + # check for a detections list and convert it to a BaseList of Detection objects (used in hld): + if self.DETECTION_LIST: + if isinstance(self.DETECTION_LIST["DETECTION"], list): + self.DETECTION_LIST = BaseList( + [ + Detection.from_dict(detection) + for detection in self.DETECTION_LIST["DETECTION"] + ] + ) + else: + self.DETECTION_LIST = BaseList( + [Detection.from_dict(self.DETECTION_LIST["DETECTION"])] + ) + + def __str__(self) -> str: + """ + String representation of the host object. + """ + if self.ASSET_ID: + return f"Host({self.ASSET_ID})" + elif self.ID: + return f"Host({self.ID})" + else: + # fall back to QG_HostID: + return f"Host({self.QG_HOSTID})" + + def __int__(self) -> int: + if self.ASSET_ID: + return self.ASSET_ID + elif self.ID: + return self.ID + else: + raise ValueError("Host object does not have an asset ID or host ID.") + + def __repr__(self) -> str: + """ + Breaking the unwritten rule of having repr be something + that can create the object for terminal space's sake. + """ + if self.ASSET_ID: + return f"Host({self.ASSET_ID})" + elif self.ID: + return f"Host({self.ID})" + else: + # fall back to QG_HostID: + return f"Host({self.QG_HOSTID})" + + def __iter__(self): + """ + Allows for iteration over the host object. + """ + for key, value in self.__dict__.items(): + yield key, value + + def has_agent(self) -> bool: + """ + Check if the host has an agent. + """ + return self.QG_HOSTID is not None + + def is_cloud_host(self) -> bool: + """ + Check if the host is a cloud host. + """ + return self.CLOUD_PROVIDER is not None + + def is_aws(self) -> bool: + """ + Check if the host is an AWS host. + """ + return self.CLOUD_PROVIDER == "AWS" + + def is_azure(self) -> bool: + """ + Check if the host is an Azure host. + """ + return self.CLOUD_PROVIDER == "Azure" + + def copy(self) -> "VMDRHost": + """ + Create a copy of the host object. + """ + return VMDRHost(**self.__dict__) + + def valid_values(self) -> list: + """ + Return a list of keys that have values. + """ + return { + k: v + for k, v in self.__dict__.items() + if v is not None + and v != "" + and v != [] + and (isinstance(v, BaseList) and v != BaseList()) + } + + @classmethod + def from_dict(cls, data: dict) -> "VMDRHost": + """ + Create a VMDRHost object from a dictionary. + """ + return cls(**data) + + +@dataclass +class VMDRID: + """ + ID - represents a Qualys GAV ID record. + + This class is used to represent a Qualys GAV ID record, either a host ID or an asset ID. + This class is only ever used if details=None in a get_host_list or get_host_list_detections API call. + """ + + ID: Union[str, int] = field( + metadata={"description": "The asset ID of the host."} + ) # add to post init to cast to int + TYPE: Literal["asset", "host"] = field( + metadata={"description": "The type of ID. Valid values are 'asset' or 'host'."}, + compare=False, + ) # field is required! + + def __post_init__(self): + """ + Cast the asset ID and host ID to integers if they are not None. + """ + + if not self.ID: + raise ValueError("ID attribute cannot be None.") + + if self.TYPE == "asset" and self.ID: + self.ID = int(self.ID) + elif self.TYPE == "host" and self.ID: + self.ID = int(self.ID) + else: + raise ValueError( + f"TYPE attribute must be 'asset' or 'host, not {self.TYPE}'" + ) + + def __str__(self) -> str: + return str(self.ID) + + def __int__(self) -> int: + return self.ID + + def __repr__(self) -> str: + if self.TYPE == "asset": + return f"VMDRID({self.ID}, type='asset')" + else: + return f"VMDRID({self.ID}, type='host')" + + def __iter__(self): + """ + Iterate over the fields of the host object. + """ + for key, value in self.__dict__.items(): + yield key, value + + def values(self): + """ + Return the values of the object. + """ + return self.__dict__.values() + + def keys(self): + """ + Return the keys of the object. + """ + return self.__dict__.keys() + + @classmethod + def from_dict(cls, data: dict) -> "VMDRID": + """ + Create a VMDRID object from a dictionary. + """ + required_keys = {"ID", "TYPE"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/ip_converters.py b/qualyspy/vmdr/data_classes/ip_converters.py index 0c853b7..ea2ec23 100644 --- a/qualyspy/vmdr/data_classes/ip_converters.py +++ b/qualyspy/vmdr/data_classes/ip_converters.py @@ -1,76 +1,76 @@ -""" -ip_converters.py - contains the IP and IPRange helpers for the VMDR module. - -The helpers convert string inputs from raw API responses into ipaddress.IPvxAddress/IPvxNetwork objects. -""" - -from ipaddress import ( - ip_address, - ip_network, - IPv4Address, - IPv6Address, - IPv4Network, - IPv6Network, - summarize_address_range, -) -from typing import Union - - -def single_ip(ip: str) -> ip_address: - """ - Converts a single IP string into an ipaddress.IPv4Address or ipaddress.IPv6Address object. - - Args: - ip (str): IP address string. - - Returns: - Union[IPv4Address, IPv6Address]: IPv4Address or IPv6Address object. - """ - return ip_address(ip) - - -def single_range(ip_range: str) -> ip_network: - """ - Converts an IP range string into an ipaddress.IPv4Network or ipaddress.IPv6Network object. - - Args: - ip_range (str): IP range string. - - Returns: - Union[IPv4Network, IPv6Network]: IPv4Network or IPv6Network object. - """ - start, end = ip_range.split("-") - - start, end = ip_address(start), ip_address(end) - - # A little confusing, but summarize_address_range returns a generator, so we use list comprehension and then grab the first element. - return ip_network([i for i in summarize_address_range(start, end)][0]) - - -# Bulk IP and IP range conversion functions: - - -def convert_ips(ips: list[str]) -> list[Union[IPv4Address, IPv6Address]]: - """ - Converts a list of IP strings into a list of ipaddress.IPv4Address or ipaddress.IPv6Address objects. - - Args: - ips (list[str]): List of IP address strings. - - Returns: - list[Union[IPv4Address, IPv6Address]]: List of IPv4Address or IPv6Address objects. - """ - return [single_ip(ip) for ip in ips] - - -def convert_ranges(ip_ranges: list[str]) -> list[Union[IPv4Network, IPv6Network]]: - """ - Converts a list of IP range strings into a list of ipaddress.IPv4Network or ipaddress.IPv6Network objects. - - Args: - ip_ranges (list[str]): List of IP range strings. - - Returns: - list[Union[IPv4Network, IPv6Network]]: List of IPv4Network or IPv6Network objects. - """ - return [single_range(ip_range) for ip_range in ip_ranges] +""" +ip_converters.py - contains the IP and IPRange helpers for the VMDR module. + +The helpers convert string inputs from raw API responses into ipaddress.IPvxAddress/IPvxNetwork objects. +""" + +from ipaddress import ( + ip_address, + ip_network, + IPv4Address, + IPv6Address, + IPv4Network, + IPv6Network, + summarize_address_range, +) +from typing import Union + + +def single_ip(ip: str) -> ip_address: + """ + Converts a single IP string into an ipaddress.IPv4Address or ipaddress.IPv6Address object. + + Params: + ip (str): IP address string. + + Returns: + Union[IPv4Address, IPv6Address]: IPv4Address or IPv6Address object. + """ + return ip_address(ip) + + +def single_range(ip_range: str) -> ip_network: + """ + Converts an IP range string into an ipaddress.IPv4Network or ipaddress.IPv6Network object. + + Params: + ip_range (str): IP range string. + + Returns: + Union[IPv4Network, IPv6Network]: IPv4Network or IPv6Network object. + """ + start, end = ip_range.split("-") + + start, end = ip_address(start), ip_address(end) + + # A little confusing, but summarize_address_range returns a generator, so we use list comprehension and then grab the first element. + return ip_network([i for i in summarize_address_range(start, end)][0]) + + +# Bulk IP and IP range conversion functions: + + +def convert_ips(ips: list[str]) -> list[Union[IPv4Address, IPv6Address]]: + """ + Converts a list of IP strings into a list of ipaddress.IPv4Address or ipaddress.IPv6Address objects. + + Params: + ips (list[str]): List of IP address strings. + + Returns: + list[Union[IPv4Address, IPv6Address]]: List of IPv4Address or IPv6Address objects. + """ + return [single_ip(ip) for ip in ips] + + +def convert_ranges(ip_ranges: list[str]) -> list[Union[IPv4Network, IPv6Network]]: + """ + Converts a list of IP range strings into a list of ipaddress.IPv4Network or ipaddress.IPv6Network objects. + + Params: + ip_ranges (list[str]): List of IP range strings. + + Returns: + list[Union[IPv4Network, IPv6Network]]: List of IPv4Network or IPv6Network objects. + """ + return [single_range(ip_range) for ip_range in ip_ranges] diff --git a/qualyspy/vmdr/data_classes/kb_entry.py b/qualyspy/vmdr/data_classes/kb_entry.py index 69ad5ab..e119ca4 100644 --- a/qualyspy/vmdr/data_classes/kb_entry.py +++ b/qualyspy/vmdr/data_classes/kb_entry.py @@ -1,279 +1,279 @@ -""" -kb_entry.py - contains the KBEntry class for the Qualyspy package. - -This class is used to represent a single entry in the Qualys KnowledgeBase (KB). -""" - -from dataclasses import dataclass, field -from typing import * -from datetime import datetime -from warnings import catch_warnings, simplefilter - -from bs4 import BeautifulSoup - -from .lists import BaseList - -from .bugtraq import Bugtraq -from .software import Software -from .vendor_reference import VendorReference -from .cve import CVEID -from .threat_intel import ThreatIntel -from .compliance import Compliance -from .tag import Tag, CloudTag - - -@dataclass(order=True) -class KBEntry: - """ - KBEntry - represents a single entry in the Qualys KnowledgeBase (KB). - - Made with order=True to allow sorting. - """ - - QID: Union[str, int] = field( - compare=True, metadata={"description": "The Qualys ID of the vulnerability."} - ) - VULN_TYPE: str = field( - metadata={"description": "The type of vulnerability."}, default="Vulnerability" - ) - SEVERITY_LEVEL: int = field( - metadata={"description": "The severity level of the vulnerability."}, default=1 - ) - TITLE: str = field( - metadata={"description": "The title of the vulnerability."}, default="No Title" - ) - CATEGORY: str = field( - metadata={"description": "The category of the vulnerability."}, - default="No Category", - ) - LAST_SERVICE_MODIFICATION_DATETIME: Optional[datetime] = field( - metadata={ - "description": "The date the vulnerability was last modified by the service." - }, - default=None, - ) - PUBLISHED_DATETIME: Optional[datetime] = field( - metadata={"description": "The date the vulnerability was published."}, - default=None, - ) - CODE_MODIFIED_DATETIME: Optional[datetime] = field( - metadata={"description": "The date the vulnerability code was last modified."}, - default=None, - ) - BUGTRAQ_LIST: Optional[BaseList] = field( - metadata={ - "description": "A list of Bugtraq IDs affected by the vulnerability." - }, - default_factory=BaseList, - ) - PATCHABLE: Optional[bool] = field( - metadata={"description": "Whether the vulnerability is patchable."}, - default=False, - ) - SOFTWARE_LIST: Optional[BaseList] = field( - metadata={"description": "A list of software affected by the vulnerability."}, - default_factory=BaseList, - ) - VENDOR_REFERENCE_LIST: Optional[BaseList] = field( - metadata={ - "description": "A list of vendor bulletin references for the vulnerability." - }, - default_factory=BaseList, - ) - CVE_LIST: Optional[BaseList] = field( - metadata={"description": "A list of CVEIDs affected by the vulnerability."}, - default_factory=BaseList, - ) - DIAGNOSIS: Optional[str] = field( - metadata={"description": "The diagnosis of the vulnerability."}, default="" - ) - CONSEQUENCE: Optional[str] = field( - metadata={"description": "The consequence of the vulnerability."}, default=None - ) - SOLUTION: Optional[str] = field( - metadata={"description": "The solution to the vulnerability."}, default=None - ) - CORRELATION: Optional[dict] = field( - metadata={"description": "The correlation details of the vulnerability."}, - default=None, - ) # TODO... maybe... - CVSS: Optional[dict] = field( - metadata={"description": "The CVSS score of the vulnerability."}, default=None - ) - CVSS_V3: Optional[dict] = field( - metadata={"description": "The CVSS v3 score of the vulnerability."}, - default=None, - ) - PCI_FLAG: Optional[bool] = field( - metadata={"description": "Whether the vulnerability is a PCI flag."}, - default=False, - ) - PCI_REASONS: Optional[dict] = field( - metadata={ - "description": "The reasons the vulnerability is valid for PCI requirements." - }, - default=None, - ) - THREAT_INTELLIGENCE: Optional[BaseList] = field( - metadata={ - "description": "The threat intelligence details of the vulnerability." - }, - default_factory=BaseList, - ) - SUPPORTED_MODULES: Optional[str] = field( - metadata={"description": "The supported modules for the vulnerability."}, - default=None, - ) - DISCOVERY: Optional[dict] = field( - metadata={"description": "The discovery details of the vulnerability."}, - default=None, - ) - IS_DISABLED: Optional[bool] = field( - metadata={"description": "Whether the vulnerability is disabled."}, - default=False, - ) - CHANGE_LOG: Optional[dict] = field( - metadata={"description": "The change log of the vulnerability."}, default=None - ) - TECHNOLOGY: Optional[str] = field( - metadata={"description": "The technology of the vulnerability."}, - default=None, - ) - COMPLIANCE_LIST: Optional[BaseList] = field( - metadata={ - "description": "The list of compliance frameworks for the vulnerability." - }, - default_factory=BaseList, - ) - LAST_CUSTOMIZATION: Optional[datetime] = field( - metadata={"description": "The date the vulnerability was last customized."}, - default=None, - ) - SOLUTION_COMMENT: Optional[str] = field( - metadata={"description": "The solution comment for the vulnerability."}, - default=None, - ) - - def __post_init__(self): - # convert certain fields out of string format: - if self.QID is not None and not isinstance(self.QID, int): - self.QID = int(self.QID) - - if self.SEVERITY_LEVEL is not None and not isinstance(self.SEVERITY_LEVEL, int): - self.SEVERITY_LEVEL = int(self.SEVERITY_LEVEL) - - DATE_FIELDS = [ - "LAST_SERVICE_MODIFICATION_DATETIME", - "PUBLISHED_DATETIME", - "CODE_MODIFIED_DATETIME", - "LAST_CUSTOMIZATION", - ] - - BOOL_FIELDS = ["PATCHABLE", "PCI_FLAG", "IS_DISABLED"] - - HTML_FIELDS = ["DIAGNOSIS", "CONSEQUENCE", "SOLUTION"] - - for field in DATE_FIELDS: - if getattr(self, field) is not None and not isinstance( - getattr(self, field), datetime - ): - # special case for LAST_CUSTOMIZATION: - if field == "LAST_CUSTOMIZATION": - if isinstance(getattr(self, field), dict): - setattr( - self, - field, - datetime.fromisoformat(getattr(self, field)["DATETIME"]), - ) - else: - setattr( - self, field, datetime.fromisoformat(getattr(self, field)) - ) - else: - setattr(self, field, datetime.fromisoformat(getattr(self, field))) - - for field in BOOL_FIELDS: - if getattr(self, field) is not None and not isinstance( - getattr(self, field), bool - ): - setattr(self, field, bool(getattr(self, field))) - - with catch_warnings(): - simplefilter("ignore") # ignore the warning about the html.parser - for field in HTML_FIELDS: - if getattr(self, field) is not None: - setattr( - self, - field, - BeautifulSoup(getattr(self, field), "html.parser").get_text(), - ) - - def __str__(self): - return f"{self.QID}" - - def __eq__(self, other): - return self.QID == other.QID - - def __hash__(self): - return hash(self.QID) - - def __iter__(self): - for key, value in self.__dict__.items(): - yield key, value - - def __contains__(self, item): - return item in self.QID or item in self.TITLE - - def copy(self): - return KBEntry(**self.__dict__) - - def is_qid(self, qid: int): - return self.QID == qid - - def pop(self, key): - return self.__dict__.pop(key) - - def get(self, key, default=None): - return self.__dict__.get(key, default) - - def items(self): - return self.__dict__.items() - - def keys(self): - return self.__dict__.keys() - - def values(self): - # return the values - return self.__dict__.values() - - def extend(self, other): - return self.__dict__.update(other.__dict__) - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a KBEntry object from a dictionary. - - This function is used to create a KBEntry object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"QID", "VULN_TYPE", "SEVERITY_LEVEL", "TITLE", "CATEGORY"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - # convert the datetime strings to datetime objects: - for key in [ - "PUBLISHED_DATETIME", - "CODE_MODIFIED_DATETIME", - "LAST_SERVICE_MODIFICATION_DATETIME", - ]: - if key in data: - data[key] = datetime.fromisoformat(data[key]) - - # call the make_lists function to create the appropriate list objects: - # data = make_lists(data) - - # and finally, create the KBEntry object: - return cls(**data) +""" +kb_entry.py - contains the KBEntry class for the Qualyspy package. + +This class is used to represent a single entry in the Qualys KnowledgeBase (KB). +""" + +from dataclasses import dataclass, field +from typing import * +from datetime import datetime +from warnings import catch_warnings, simplefilter + +from bs4 import BeautifulSoup + +from .lists import BaseList + +from .bugtraq import Bugtraq +from .software import Software +from .vendor_reference import VendorReference +from .cve import CVEID +from .threat_intel import ThreatIntel +from .compliance import Compliance +from .tag import Tag, CloudTag + + +@dataclass(order=True) +class KBEntry: + """ + KBEntry - represents a single entry in the Qualys KnowledgeBase (KB). + + Made with order=True to allow sorting. + """ + + QID: Union[str, int] = field( + compare=True, metadata={"description": "The Qualys ID of the vulnerability."} + ) + VULN_TYPE: str = field( + metadata={"description": "The type of vulnerability."}, default="Vulnerability" + ) + SEVERITY_LEVEL: int = field( + metadata={"description": "The severity level of the vulnerability."}, default=1 + ) + TITLE: str = field( + metadata={"description": "The title of the vulnerability."}, default="No Title" + ) + CATEGORY: str = field( + metadata={"description": "The category of the vulnerability."}, + default="No Category", + ) + LAST_SERVICE_MODIFICATION_DATETIME: Optional[datetime] = field( + metadata={ + "description": "The date the vulnerability was last modified by the service." + }, + default=None, + ) + PUBLISHED_DATETIME: Optional[datetime] = field( + metadata={"description": "The date the vulnerability was published."}, + default=None, + ) + CODE_MODIFIED_DATETIME: Optional[datetime] = field( + metadata={"description": "The date the vulnerability code was last modified."}, + default=None, + ) + BUGTRAQ_LIST: Optional[BaseList] = field( + metadata={ + "description": "A list of Bugtraq IDs affected by the vulnerability." + }, + default_factory=BaseList, + ) + PATCHABLE: Optional[bool] = field( + metadata={"description": "Whether the vulnerability is patchable."}, + default=False, + ) + SOFTWARE_LIST: Optional[BaseList] = field( + metadata={"description": "A list of software affected by the vulnerability."}, + default_factory=BaseList, + ) + VENDOR_REFERENCE_LIST: Optional[BaseList] = field( + metadata={ + "description": "A list of vendor bulletin references for the vulnerability." + }, + default_factory=BaseList, + ) + CVE_LIST: Optional[BaseList] = field( + metadata={"description": "A list of CVEIDs affected by the vulnerability."}, + default_factory=BaseList, + ) + DIAGNOSIS: Optional[str] = field( + metadata={"description": "The diagnosis of the vulnerability."}, default="" + ) + CONSEQUENCE: Optional[str] = field( + metadata={"description": "The consequence of the vulnerability."}, default=None + ) + SOLUTION: Optional[str] = field( + metadata={"description": "The solution to the vulnerability."}, default=None + ) + CORRELATION: Optional[dict] = field( + metadata={"description": "The correlation details of the vulnerability."}, + default=None, + ) # TODO... maybe... + CVSS: Optional[dict] = field( + metadata={"description": "The CVSS score of the vulnerability."}, default=None + ) + CVSS_V3: Optional[dict] = field( + metadata={"description": "The CVSS v3 score of the vulnerability."}, + default=None, + ) + PCI_FLAG: Optional[bool] = field( + metadata={"description": "Whether the vulnerability is a PCI flag."}, + default=False, + ) + PCI_REASONS: Optional[dict] = field( + metadata={ + "description": "The reasons the vulnerability is valid for PCI requirements." + }, + default=None, + ) + THREAT_INTELLIGENCE: Optional[BaseList] = field( + metadata={ + "description": "The threat intelligence details of the vulnerability." + }, + default_factory=BaseList, + ) + SUPPORTED_MODULES: Optional[str] = field( + metadata={"description": "The supported modules for the vulnerability."}, + default=None, + ) + DISCOVERY: Optional[dict] = field( + metadata={"description": "The discovery details of the vulnerability."}, + default=None, + ) + IS_DISABLED: Optional[bool] = field( + metadata={"description": "Whether the vulnerability is disabled."}, + default=False, + ) + CHANGE_LOG: Optional[dict] = field( + metadata={"description": "The change log of the vulnerability."}, default=None + ) + TECHNOLOGY: Optional[str] = field( + metadata={"description": "The technology of the vulnerability."}, + default=None, + ) + COMPLIANCE_LIST: Optional[BaseList] = field( + metadata={ + "description": "The list of compliance frameworks for the vulnerability." + }, + default_factory=BaseList, + ) + LAST_CUSTOMIZATION: Optional[datetime] = field( + metadata={"description": "The date the vulnerability was last customized."}, + default=None, + ) + SOLUTION_COMMENT: Optional[str] = field( + metadata={"description": "The solution comment for the vulnerability."}, + default=None, + ) + + def __post_init__(self): + # convert certain fields out of string format: + if self.QID is not None and not isinstance(self.QID, int): + self.QID = int(self.QID) + + if self.SEVERITY_LEVEL is not None and not isinstance(self.SEVERITY_LEVEL, int): + self.SEVERITY_LEVEL = int(self.SEVERITY_LEVEL) + + DATE_FIELDS = [ + "LAST_SERVICE_MODIFICATION_DATETIME", + "PUBLISHED_DATETIME", + "CODE_MODIFIED_DATETIME", + "LAST_CUSTOMIZATION", + ] + + BOOL_FIELDS = ["PATCHABLE", "PCI_FLAG", "IS_DISABLED"] + + HTML_FIELDS = ["DIAGNOSIS", "CONSEQUENCE", "SOLUTION"] + + for field in DATE_FIELDS: + if getattr(self, field) is not None and not isinstance( + getattr(self, field), datetime + ): + # special case for LAST_CUSTOMIZATION: + if field == "LAST_CUSTOMIZATION": + if isinstance(getattr(self, field), dict): + setattr( + self, + field, + datetime.fromisoformat(getattr(self, field)["DATETIME"]), + ) + else: + setattr( + self, field, datetime.fromisoformat(getattr(self, field)) + ) + else: + setattr(self, field, datetime.fromisoformat(getattr(self, field))) + + for field in BOOL_FIELDS: + if getattr(self, field) is not None and not isinstance( + getattr(self, field), bool + ): + setattr(self, field, bool(getattr(self, field))) + + with catch_warnings(): + simplefilter("ignore") # ignore the warning about the html.parser + for field in HTML_FIELDS: + if getattr(self, field) is not None: + setattr( + self, + field, + BeautifulSoup(getattr(self, field), "html.parser").get_text(), + ) + + def __str__(self): + return f"{self.QID}" + + def __eq__(self, other): + return self.QID == other.QID + + def __hash__(self): + return hash(self.QID) + + def __iter__(self): + for key, value in self.__dict__.items(): + yield key, value + + def __contains__(self, item): + return item in self.QID or item in self.TITLE + + def copy(self): + return KBEntry(**self.__dict__) + + def is_qid(self, qid: int): + return self.QID == qid + + def pop(self, key): + return self.__dict__.pop(key) + + def get(self, key, default=None): + return self.__dict__.get(key, default) + + def items(self): + return self.__dict__.items() + + def keys(self): + return self.__dict__.keys() + + def values(self): + # return the values + return self.__dict__.values() + + def extend(self, other): + return self.__dict__.update(other.__dict__) + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a KBEntry object from a dictionary. + + This function is used to create a KBEntry object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"QID", "VULN_TYPE", "SEVERITY_LEVEL", "TITLE", "CATEGORY"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + # convert the datetime strings to datetime objects: + for key in [ + "PUBLISHED_DATETIME", + "CODE_MODIFIED_DATETIME", + "LAST_SERVICE_MODIFICATION_DATETIME", + ]: + if key in data: + data[key] = datetime.fromisoformat(data[key]) + + # call the make_lists function to create the appropriate list objects: + # data = make_lists(data) + + # and finally, create the KBEntry object: + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/lists/__init__.py b/qualyspy/vmdr/data_classes/lists/__init__.py index 8306d75..410070b 100644 --- a/qualyspy/vmdr/data_classes/lists/__init__.py +++ b/qualyspy/vmdr/data_classes/lists/__init__.py @@ -1,5 +1,5 @@ -""" -lists module. - contains the dataclasses for the lists module. -""" - -from .base_list import BaseList +""" +lists module. - contains the dataclasses for the lists module. +""" + +from .base_list import BaseList diff --git a/qualyspy/vmdr/data_classes/lists/base_list.py b/qualyspy/vmdr/data_classes/lists/base_list.py index ebf186e..bc0008b 100644 --- a/qualyspy/vmdr/data_classes/lists/base_list.py +++ b/qualyspy/vmdr/data_classes/lists/base_list.py @@ -1,20 +1,20 @@ -""" -base_list.py - contains the BaseList class for the Qualyspy package. - -The BaseList class is used to contain custom class objects in a list. -""" - - -class BaseList(list): - """ - BaseList - represents a base list class for the Qualyspy package. - - Essentially, this is a regular Python list with some additional functionality. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def __str__(self) -> str: - # instead of returning "[...]", return a comma-separated string of the objects in the list - return ", ".join(str(obj) for obj in self) +""" +base_list.py - contains the BaseList class for the Qualyspy package. + +The BaseList class is used to contain custom class objects in a list. +""" + + +class BaseList(list): + """ + BaseList - represents a base list class for the Qualyspy package. + + Essentially, this is a regular Python list with some additional functionality. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __str__(self) -> str: + # instead of returning "[...]", return a comma-separated string of the objects in the list + return ", ".join(str(obj) for obj in self) diff --git a/qualyspy/vmdr/data_classes/qds.py b/qualyspy/vmdr/data_classes/qds.py index 3780ddd..d492cb8 100644 --- a/qualyspy/vmdr/data_classes/qds.py +++ b/qualyspy/vmdr/data_classes/qds.py @@ -1,70 +1,70 @@ -""" -qds.py - contains the QDS dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass(order=True) -class QDS: - """ - QDS - represents a single QDS score. - - made with order=True to allow for sorting of QDS scores. - """ - - SEVERITY: str = field( - metadata={"description": "The rating of the QDS score."}, compare=False - ) - SCORE: int = field( - metadata={"description": "The actual score itself as an int."}, - default=0, - ) - - def __post_init__(self): - # make sure that the SEVERITY is a str: - if not isinstance(self.SEVERITY, str): - raise TypeError(f"QDS SEVERITY must be a str, not {type(self.SEVERITY)}") - # and that text is an int: - if not isinstance(self.SCORE, int): - raise TypeError(f"QDS SCORE must be an int, not {type(self.SCORE)}") - - def __str__(self): - return str(self.SCORE) - - def __int__(self): - return self.SCORE - - def __contains__(self, item): - return item in self.SEVERITY or item in self.SCORE - - def copy(self): - return QDS(SEVERITY=self.SEVERITY, SCORE=self.SCORE) - - def is_severity(self, severity: str): - return self.SEVERITY == severity - - def get_severity(self): - return self.SEVERITY - - def get_score(self): - return self.SCORE - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a QDS object from a dictionary. - - Args: - data (dict): The dictionary containing the data for the QDS object. - - Returns: - QDS: The QDS object created from the dictionary. - """ - required_keys = {"SEVERITY", "SCORE"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - return cls(**data) +""" +qds.py - contains the QDS dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass(order=True) +class QDS: + """ + QDS - represents a single QDS score. + + made with order=True to allow for sorting of QDS scores. + """ + + SEVERITY: str = field( + metadata={"description": "The rating of the QDS score."}, compare=False + ) + SCORE: int = field( + metadata={"description": "The actual score itself as an int."}, + default=0, + ) + + def __post_init__(self): + # make sure that the SEVERITY is a str: + if not isinstance(self.SEVERITY, str): + raise TypeError(f"QDS SEVERITY must be a str, not {type(self.SEVERITY)}") + # and that text is an int: + if not isinstance(self.SCORE, int): + raise TypeError(f"QDS SCORE must be an int, not {type(self.SCORE)}") + + def __str__(self): + return str(self.SCORE) + + def __int__(self): + return self.SCORE + + def __contains__(self, item): + return item in self.SEVERITY or item in self.SCORE + + def copy(self): + return QDS(SEVERITY=self.SEVERITY, SCORE=self.SCORE) + + def is_severity(self, severity: str): + return self.SEVERITY == severity + + def get_severity(self): + return self.SEVERITY + + def get_score(self): + return self.SCORE + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a QDS object from a dictionary. + + Params: + data (dict): The dictionary containing the data for the QDS object. + + Returns: + QDS: The QDS object created from the dictionary. + """ + required_keys = {"SEVERITY", "SCORE"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/qds_factor.py b/qualyspy/vmdr/data_classes/qds_factor.py index 43ff417..8152551 100644 --- a/qualyspy/vmdr/data_classes/qds_factor.py +++ b/qualyspy/vmdr/data_classes/qds_factor.py @@ -1,101 +1,101 @@ -""" -qds_factor.py - contains the QDSFactor dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class QDSFactor: - """ - QDSFactor - represents a single Qualys Detection Score (QDS) factor. - """ - - NAME: int = field(metadata={"description": "The name of the factor."}) - VALUE: Union[str, int, float] = field( - metadata={ - "description": "The value of the factor. Can be a str or number depending on what the @name is." - }, - default="", - ) - - def __post_init__(self): - # make sure that the ID is an integer: - if not isinstance(self.NAME, str): - raise TypeError(f"QDSFactor NAME must be a string, not {type(self.NAME)}") - # check if the VALUE can be interpreted as a number: - for val in (float, int): - try: - # special case for CVSS version, as qualys prepends as 'v' to the number: - if self.NAME == "CVSS": - self.VALUE = self.VALUE[1:] - - self.VALUE = val(self.VALUE) - break - except ValueError: - pass - - def __str__(self): - return str(self.VALUE) - - def __contains__(self, item): - return item in self.NAME or item in self.VALUE - - def copy(self): - return QDSFactor(ID=self.NAME, TEXT=self.VALUE) - - def is_id(self, id: int): - return self.NAME == id - - def is_text(self, text: str): - return self.VALUE == text - - def has_rti(self): - return self.NAME == "RTI" - - def has_cvss_score(self): - return self.NAME == "CVSS" - - def has_epss(self): - return self.NAME == "epss" - - def get_epss(self): - if self.has_epss(): - return self.VALUE - - def get_cvss_score(self): - if self.has_cvss_score(): - return self.VALUE - - def has_malware_hash(self): - return self.NAME == "malware_hash" - - def get_malware_hash(self): - if self.has_malware_hash(): - return self.VALUE - - def get_rti(self): - if self.has_rti(): - return self.VALUE - - def get_name(self): - return self.NAME - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a QDSFactor object from a dictionary. - - Args: - data (dict): The dictionary containing the data for the QDSFactor object. - - Returns: - QDSFactor: The QDSFactor object created from the dictionary. - """ - required_keys = {"NAME", "VALUE"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - return cls(**data) +""" +qds_factor.py - contains the QDSFactor dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class QDSFactor: + """ + QDSFactor - represents a single Qualys Detection Score (QDS) factor. + """ + + NAME: int = field(metadata={"description": "The name of the factor."}) + VALUE: Union[str, int, float] = field( + metadata={ + "description": "The value of the factor. Can be a str or number depending on what the @name is." + }, + default="", + ) + + def __post_init__(self): + # make sure that the ID is an integer: + if not isinstance(self.NAME, str): + raise TypeError(f"QDSFactor NAME must be a string, not {type(self.NAME)}") + # check if the VALUE can be interpreted as a number: + for val in (float, int): + try: + # special case for CVSS version, as qualys prepends as 'v' to the number: + if self.NAME == "CVSS": + self.VALUE = self.VALUE[1:] + + self.VALUE = val(self.VALUE) + break + except ValueError: + pass + + def __str__(self): + return str(self.VALUE) + + def __contains__(self, item): + return item in self.NAME or item in self.VALUE + + def copy(self): + return QDSFactor(ID=self.NAME, TEXT=self.VALUE) + + def is_id(self, id: int): + return self.NAME == id + + def is_text(self, text: str): + return self.VALUE == text + + def has_rti(self): + return self.NAME == "RTI" + + def has_cvss_score(self): + return self.NAME == "CVSS" + + def has_epss(self): + return self.NAME == "epss" + + def get_epss(self): + if self.has_epss(): + return self.VALUE + + def get_cvss_score(self): + if self.has_cvss_score(): + return self.VALUE + + def has_malware_hash(self): + return self.NAME == "malware_hash" + + def get_malware_hash(self): + if self.has_malware_hash(): + return self.VALUE + + def get_rti(self): + if self.has_rti(): + return self.VALUE + + def get_name(self): + return self.NAME + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a QDSFactor object from a dictionary. + + Params: + data (dict): The dictionary containing the data for the QDSFactor object. + + Returns: + QDSFactor: The QDSFactor object created from the dictionary. + """ + required_keys = {"NAME", "VALUE"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/software.py b/qualyspy/vmdr/data_classes/software.py index c3ae23f..a6ba8e9 100644 --- a/qualyspy/vmdr/data_classes/software.py +++ b/qualyspy/vmdr/data_classes/software.py @@ -1,58 +1,58 @@ -""" -software.py - contains the Software dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class Software: - """ - Software - represents a single software entry in a SoftwareList. - - This class is used to represent a single software entry in a SoftwareList, - which is used to represent the software that is affected by a vulnerability. - """ - - PRODUCT: str = field(metadata={"description": "The name of the software."}) - VENDOR: str = field( - metadata={"description": "The vendor of the software."}, - default="", - compare=False, - ) - - def __str__(self): - return self.PRODUCT - - def __contains__( - self, item - ): # allows us to use the 'in' operator. for example, 'if "Adobe" in software'. this is a fuzzy search. - # see if it was found in the name or vendor: - return item in self.PRODUCT or item in self.VENDOR - - def copy(self): - return Software(PRODUCT=self.PRODUCT, VENDOR=self.VENDOR) - - def is_vendor(self, vendor: str): - return self.VENDOR.lower() == vendor.lower() - - def is_name(self, product: str): - return self.PRODUCT.lower() == product.lower() - - @classmethod - def from_dict(cls, data: Union[dict, list]): - """ - from_dict - create a Software object from a dictionary. - - This function is used to create a Software object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"PRODUCT", "VENDOR"} - - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +software.py - contains the Software dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class Software: + """ + Software - represents a single software entry in a SoftwareList. + + This class is used to represent a single software entry in a SoftwareList, + which is used to represent the software that is affected by a vulnerability. + """ + + PRODUCT: str = field(metadata={"description": "The name of the software."}) + VENDOR: str = field( + metadata={"description": "The vendor of the software."}, + default="", + compare=False, + ) + + def __str__(self): + return self.PRODUCT + + def __contains__( + self, item + ): # allows us to use the 'in' operator. for example, 'if "Adobe" in software'. this is a fuzzy search. + # see if it was found in the name or vendor: + return item in self.PRODUCT or item in self.VENDOR + + def copy(self): + return Software(PRODUCT=self.PRODUCT, VENDOR=self.VENDOR) + + def is_vendor(self, vendor: str): + return self.VENDOR.lower() == vendor.lower() + + def is_name(self, product: str): + return self.PRODUCT.lower() == product.lower() + + @classmethod + def from_dict(cls, data: Union[dict, list]): + """ + from_dict - create a Software object from a dictionary. + + This function is used to create a Software object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"PRODUCT", "VENDOR"} + + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/tag.py b/qualyspy/vmdr/data_classes/tag.py index 341fcf2..e407ebe 100644 --- a/qualyspy/vmdr/data_classes/tag.py +++ b/qualyspy/vmdr/data_classes/tag.py @@ -1,139 +1,139 @@ -""" -tag.py - contains the CloudTag and Tag dataclasses for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * -from datetime import datetime - - -@dataclass -class Tag: - """ - Tag - represents a single tag in a TagList. - - This class is used to represent a single tag in a TagList, aka a tag on an asset. - """ - - TAG_ID: Union[str, int] = field(metadata={"description": "The ID of the tag."}) - NAME: str = field(metadata={"description": "The name of the tag."}) - - def __post_init__(self): - # check that id is a string or int: - if not isinstance(self.TAG_ID, (str, int)): - raise TypeError(f"Tag ID must be a string or int, not {type(self.TAG_ID)}") - - # cast ID to an int if it is a string - if isinstance(self.TAG_ID, str): - self.TAG_ID = int(self.TAG_ID) - - def __str__(self) -> str: - return self.NAME - - def __contains__(self, item): - # see if it was found in the name or id: - return item in self.TAG_ID or item in self.NAME - - def copy(self): - return Tag(TAG_ID=self.TAG_ID, NAME=self.NAME) - - def is_id(self, id: int): - return self.TAG_ID == id - - def is_name(self, name: str): - return self.NAME == name - - def __iter__(self): - yield self.TAG_ID - yield self.NAME - - def __dict__(self): - return {"TAG_ID": self.TAG_ID, "NAME": self.NAME} - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a Tag object from a dictionary. - - This function is used to create a Tag object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"TAG_ID", "NAME"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - # cast ID to an int if it is a string - if isinstance(data["TAG_ID"], str): - data["TAG_ID"] = int(data["TAG_ID"]) - - return cls(**data) - - -@dataclass -class CloudTag: - """ - CloudTag - represents a single tag in a CloudTagList. - - This class is used to represent a single tag in a CloudTagList, aka a tag on a cloud asset. - """ - - NAME: str = field(metadata={"description": "The name of the tag."}) - VALUE: str = field(metadata={"description": "The value of the tag."}) - LAST_SUCCESS_DATE: Optional[Union[str, datetime]] = field( - default=None, - metadata={"description": "The last successful date of the tag."}, - ) - - def __post_init__(self): - # check that the last success date is a string or datetime: - if self.LAST_SUCCESS_DATE is not None and not isinstance( - self.LAST_SUCCESS_DATE, (str, datetime) - ): - raise TypeError( - f"Last success date must be a string or datetime, not {type(self.LAST_SUCCESS_DATE)}" - ) - - # cast last success date to a datetime if it is a string - if isinstance(self.LAST_SUCCESS_DATE, str): - self.LAST_SUCCESS_DATE = datetime.fromisoformat(self.LAST_SUCCESS_DATE) - - def __str__(self) -> str: - return f"{self.NAME}:{self.VALUE}" - - def __dict__(self): - return { - "NAME": self.NAME, - "VALUE": self.VALUE, - "LAST_SUCCESS_DATE": self.LAST_SUCCESS_DATE, - } - - def __contains__(self, item): - # see if it was found in the name or value: - return item in self.NAME or item in self.VALUE - - def copy(self): - return CloudTag(NAME=self.NAME, VALUE=self.VALUE) - - def is_name(self, name: str): - return self.NAME == name - - def is_value(self, value: str): - return self.VALUE == value - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a CloudTag object from a dictionary. - - This function is used to create a CloudTag object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"NAME", "VALUE"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +tag.py - contains the CloudTag and Tag dataclasses for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * +from datetime import datetime + + +@dataclass +class Tag: + """ + Tag - represents a single tag in a TagList. + + This class is used to represent a single tag in a TagList, aka a tag on an asset. + """ + + TAG_ID: Union[str, int] = field(metadata={"description": "The ID of the tag."}) + NAME: str = field(metadata={"description": "The name of the tag."}) + + def __post_init__(self): + # check that id is a string or int: + if not isinstance(self.TAG_ID, (str, int)): + raise TypeError(f"Tag ID must be a string or int, not {type(self.TAG_ID)}") + + # cast ID to an int if it is a string + if isinstance(self.TAG_ID, str): + self.TAG_ID = int(self.TAG_ID) + + def __str__(self) -> str: + return self.NAME + + def __contains__(self, item): + # see if it was found in the name or id: + return item in self.TAG_ID or item in self.NAME + + def copy(self): + return Tag(TAG_ID=self.TAG_ID, NAME=self.NAME) + + def is_id(self, id: int): + return self.TAG_ID == id + + def is_name(self, name: str): + return self.NAME == name + + def __iter__(self): + yield self.TAG_ID + yield self.NAME + + def __dict__(self): + return {"TAG_ID": self.TAG_ID, "NAME": self.NAME} + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a Tag object from a dictionary. + + This function is used to create a Tag object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"TAG_ID", "NAME"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + # cast ID to an int if it is a string + if isinstance(data["TAG_ID"], str): + data["TAG_ID"] = int(data["TAG_ID"]) + + return cls(**data) + + +@dataclass +class CloudTag: + """ + CloudTag - represents a single tag in a CloudTagList. + + This class is used to represent a single tag in a CloudTagList, aka a tag on a cloud asset. + """ + + NAME: str = field(metadata={"description": "The name of the tag."}) + VALUE: str = field(metadata={"description": "The value of the tag."}) + LAST_SUCCESS_DATE: Optional[Union[str, datetime]] = field( + default=None, + metadata={"description": "The last successful date of the tag."}, + ) + + def __post_init__(self): + # check that the last success date is a string or datetime: + if self.LAST_SUCCESS_DATE is not None and not isinstance( + self.LAST_SUCCESS_DATE, (str, datetime) + ): + raise TypeError( + f"Last success date must be a string or datetime, not {type(self.LAST_SUCCESS_DATE)}" + ) + + # cast last success date to a datetime if it is a string + if isinstance(self.LAST_SUCCESS_DATE, str): + self.LAST_SUCCESS_DATE = datetime.fromisoformat(self.LAST_SUCCESS_DATE) + + def __str__(self) -> str: + return f"{self.NAME}:{self.VALUE}" + + def __dict__(self): + return { + "NAME": self.NAME, + "VALUE": self.VALUE, + "LAST_SUCCESS_DATE": self.LAST_SUCCESS_DATE, + } + + def __contains__(self, item): + # see if it was found in the name or value: + return item in self.NAME or item in self.VALUE + + def copy(self): + return CloudTag(NAME=self.NAME, VALUE=self.VALUE) + + def is_name(self, name: str): + return self.NAME == name + + def is_value(self, value: str): + return self.VALUE == value + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a CloudTag object from a dictionary. + + This function is used to create a CloudTag object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"NAME", "VALUE"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/threat_intel.py b/qualyspy/vmdr/data_classes/threat_intel.py index 6564e72..9ee8764 100644 --- a/qualyspy/vmdr/data_classes/threat_intel.py +++ b/qualyspy/vmdr/data_classes/threat_intel.py @@ -1,61 +1,61 @@ -""" -threat_intel.py - contains the ThreatIntel dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class ThreatIntel: - """ - ThreatIntel - represents a single threat intel entry in a ThreatIntelList. - """ - - ID: int = field(metadata={"description": "The ID of the threat intel."}) - TEXT: str = field( - metadata={"description": "The text of the threat intel."}, - default="", - compare=False, - ) - - def __post_init__(self): - # make sure that the ID is an integer: - if not isinstance(self.ID, int): - raise TypeError(f"ThreatIntel ID must be an integer, not {type(self.ID)}") - # and that text is a string: - if not isinstance(self.TEXT, str): - raise TypeError(f"ThreatIntel TEXT must be a string, not {type(self.TEXT)}") - - def __str__(self): - return str(self.TEXT) - - def __contains__(self, item): - return item in self.ID or item in self.TEXT - - def copy(self): - return ThreatIntel(ID=self.ID, TEXT=self.TEXT) - - def is_id(self, id: int): - return self.ID == id - - def is_text(self, text: str): - return self.TEXT == text - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a ThreatIntel object from a dictionary. - - Args: - data (dict): The dictionary containing the data for the ThreatIntel object. - - Returns: - ThreatIntel: The ThreatIntel object created from the dictionary. - """ - required_keys = {"ID"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - return cls(**data) +""" +threat_intel.py - contains the ThreatIntel dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class ThreatIntel: + """ + ThreatIntel - represents a single threat intel entry in a ThreatIntelList. + """ + + ID: int = field(metadata={"description": "The ID of the threat intel."}) + TEXT: str = field( + metadata={"description": "The text of the threat intel."}, + default="", + compare=False, + ) + + def __post_init__(self): + # make sure that the ID is an integer: + if not isinstance(self.ID, int): + raise TypeError(f"ThreatIntel ID must be an integer, not {type(self.ID)}") + # and that text is a string: + if not isinstance(self.TEXT, str): + raise TypeError(f"ThreatIntel TEXT must be a string, not {type(self.TEXT)}") + + def __str__(self): + return str(self.TEXT) + + def __contains__(self, item): + return item in self.ID or item in self.TEXT + + def copy(self): + return ThreatIntel(ID=self.ID, TEXT=self.TEXT) + + def is_id(self, id: int): + return self.ID == id + + def is_text(self, text: str): + return self.TEXT == text + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a ThreatIntel object from a dictionary. + + Params: + data (dict): The dictionary containing the data for the ThreatIntel object. + + Returns: + ThreatIntel: The ThreatIntel object created from the dictionary. + """ + required_keys = {"ID"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/vendor_reference.py b/qualyspy/vmdr/data_classes/vendor_reference.py index 2319674..2eb06c9 100644 --- a/qualyspy/vmdr/data_classes/vendor_reference.py +++ b/qualyspy/vmdr/data_classes/vendor_reference.py @@ -1,62 +1,62 @@ -""" -vendor_reference.py - contains the VendorReference dataclass for the Qualys VMDR module. -""" - -from dataclasses import dataclass, field -from typing import * - - -@dataclass -class VendorReference: - """ - VendorReference - represents a single vendor bulletin reference in a ReferenceList. - - This class is used to represent a single vendor bulletin reference in a ReferenceList, - which is used to represent the vendor bulletin references for a vulnerability. - """ - - ID: str = field(metadata={"description": "The ID of the vendor reference."}) - URL: str = field( - metadata={"description": "The URL of the vendor reference."}, - default="", - compare=False, - ) - - def __post_init__(self): - # check that url is a string: - if not isinstance(self.URL, str): - raise TypeError( - f"VendorReference URL must be a string, not {type(self.URL)}" - ) - - def __str__(self) -> str: - return self.ID - - def __contains__(self, item): - # see if it was found in the name or vendor: - return item in self.ID or item in self.URL - - def copy(self): - return VendorReference(ID=self.ID, URL=self.URL) - - def is_id(self, id: int): - return self.ID == id - - def is_url(self, url: str): - return self.URL == url - - @classmethod - def from_dict(cls, data: dict): - """ - from_dict - create a Software object from a dictionary. - - This function is used to create a Software object from a dictionary. - """ - # make sure that the dictionary has the required keys and nothing else: - required_keys = {"ID"} - if not required_keys.issubset(data.keys()): - raise ValueError( - f"Dictionary must contain the following keys: {required_keys}" - ) - - return cls(**data) +""" +vendor_reference.py - contains the VendorReference dataclass for the Qualys VMDR module. +""" + +from dataclasses import dataclass, field +from typing import * + + +@dataclass +class VendorReference: + """ + VendorReference - represents a single vendor bulletin reference in a ReferenceList. + + This class is used to represent a single vendor bulletin reference in a ReferenceList, + which is used to represent the vendor bulletin references for a vulnerability. + """ + + ID: str = field(metadata={"description": "The ID of the vendor reference."}) + URL: str = field( + metadata={"description": "The URL of the vendor reference."}, + default="", + compare=False, + ) + + def __post_init__(self): + # check that url is a string: + if not isinstance(self.URL, str): + raise TypeError( + f"VendorReference URL must be a string, not {type(self.URL)}" + ) + + def __str__(self) -> str: + return self.ID + + def __contains__(self, item): + # see if it was found in the name or vendor: + return item in self.ID or item in self.URL + + def copy(self): + return VendorReference(ID=self.ID, URL=self.URL) + + def is_id(self, id: int): + return self.ID == id + + def is_url(self, url: str): + return self.URL == url + + @classmethod + def from_dict(cls, data: dict): + """ + from_dict - create a Software object from a dictionary. + + This function is used to create a Software object from a dictionary. + """ + # make sure that the dictionary has the required keys and nothing else: + required_keys = {"ID"} + if not required_keys.issubset(data.keys()): + raise ValueError( + f"Dictionary must contain the following keys: {required_keys}" + ) + + return cls(**data) diff --git a/qualyspy/vmdr/data_classes/vmscan.py b/qualyspy/vmdr/data_classes/vmscan.py index 50075d4..1ff8389 100644 --- a/qualyspy/vmdr/data_classes/vmscan.py +++ b/qualyspy/vmdr/data_classes/vmscan.py @@ -1,162 +1,162 @@ -""" -vmscans.py - Contains the VMScan data class. -""" - -from typing import Union, Literal -from datetime import datetime, timedelta -from dataclasses import dataclass, field, asdict - -from .lists.base_list import BaseList -from .ip_converters import * - - -def parse_duration(duration: str) -> timedelta: - """ - Parse a duration string into a timedelta object. - - Args: - duration (str): Duration string in the format HH:MM:SS or N days HH:MM:SS. - - Returns: - timedelta: Timedelta object representing the duration. - """ - if "days" in duration: - days, time = duration.split(" days ") - days = int(days) - else: - days = 0 - time = duration - - hours, minutes, seconds = map(int, time.split(":")) - return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) - - -@dataclass(order=True) -class VMScan: - """ - Data class to hold VM Scan data. - """ - - REF: str = field( - metadata={ - "description": "Scan reference ID. Formatted like scan/1234567890123456, compliance/1234567890123456, or qscap/1234567890123456." - }, - default=None, - ) - TYPE: Literal["On-Demand", "API", "Scheduled"] = field( - metadata={"description": "Type of scan. On-Demand, API, Scheduled."}, - default=None, - ) - TITLE: str = field(metadata={"description": "Title of the scan."}, default=None) - USER_LOGIN: str = field( - metadata={"description": "Owner of the scan."}, default=None - ) - LAUNCH_DATETIME: Union[str, datetime] = field( - metadata={"description": "Datetime the scan was launched."}, default=None - ) # post init to convert to datetime - DURATION: Union[str, timedelta] = field( - metadata={"description": "Duration of the scan."}, default=None - ) # post init to convert to timedelta - PROCESSING_PRIORITY: str = field( - metadata={"description": "Processing priority of the scan."}, default=None - ) - PROCESSED: bool = field( - metadata={"description": "Whether the scan has been processed."}, default=None - ) - STATUS: dict = field( - metadata={"description": "Status of the scan."}, default=None - ) # STATE is in this dict. get STATE in post init - STATE: str = field( - metadata={"description": "The state of the scan."}, init=False, default=None - ) # populated in post init - TARGET: Union[str, BaseList[str], BaseList[ip_address]] = field( - metadata={"description": "Target of the scan."}, default=None - ) - OPTION_PROFILE: dict = field( - metadata={"description": "Option profile of the scan."}, default=None - ) - ASSET_GROUP_TITLE_LIST: Union[str, BaseList[str]] = field( - metadata={"description": "Asset group title list."}, default=None - ) - - def __post_init__(self): - """ - Post init function to convert the LAUNCH_DATETIME and DURATION fields to datetime and timedelta objects. - """ - if self.LAUNCH_DATETIME: - self.LAUNCH_DATETIME = datetime.strptime( - self.LAUNCH_DATETIME, "%Y-%m-%dT%H:%M:%SZ" - ) - if self.DURATION: - self.DURATION = ( - parse_duration(self.DURATION) - if self.DURATION not in ["Pending", "Running"] - else self.DURATION - ) - - if self.STATUS: - self.STATE = self.STATUS["STATE"] - - # For the IPs/IP ranges in the TARGET field, determine if it is a single IP/range, or a comma separated string of IPs/ranges. - if self.TARGET: - final_list = BaseList() - self.TARGET = self.TARGET.split(",") - for t in self.TARGET: - if "-" in t: - t = single_range(t) - else: - t = single_ip(t) - final_list.append(t) - self.TARGET = final_list - - if self.PROCESSED: - self.PROCESSED = bool(self.PROCESSED) - - # create the baselist for the asset group titles - # first, check if [ASSET_GROUP_TITLE_LIST] is a dict. If it is, convert it to a list - if self.ASSET_GROUP_TITLE_LIST: - if isinstance(self.ASSET_GROUP_TITLE_LIST, dict): - self.ASSET_GROUP_TITLE_LIST = [self.ASSET_GROUP_TITLE_LIST] - # create the BaseList object - final_list = BaseList() - for ag in self.ASSET_GROUP_TITLE_LIST: - final_list.append(ag["ASSET_GROUP_TITLE"]) - self.ASSET_GROUP_TITLE_LIST = final_list - - def to_dict(self) -> dict: - """ - Convert the VMScan object to a dictionary. - """ - return asdict(self) - - @classmethod - def from_dict(cls, data: dict): - """ - Create a VMScan object from a dictionary. - """ - return cls(**data) - - def __str__(self): - return f"VMScan: {self.TITLE} - {self.REF}" - - def __int__(self): - return int(self.REF.split("/")[-1]) - - def keys(self): - return self.__dict__.keys() - - def values(self): - return self.__dict__.values() - - def items(self): - return self.__dict__.items() - - def valid_values(self): - return { - k: v - for k, v in self.__dict__.items() - if v is not None - and v != "" - and v != [] - and (isinstance(v, BaseList) and v != BaseList()) - } +""" +vmscans.py - Contains the VMScan data class. +""" + +from typing import Union, Literal +from datetime import datetime, timedelta +from dataclasses import dataclass, field, asdict + +from .lists.base_list import BaseList +from .ip_converters import * + + +def parse_duration(duration: str) -> timedelta: + """ + Parse a duration string into a timedelta object. + + Params: + duration (str): Duration string in the format HH:MM:SS or N days HH:MM:SS. + + Returns: + timedelta: Timedelta object representing the duration. + """ + if "days" in duration: + days, time = duration.split(" days ") + days = int(days) + else: + days = 0 + time = duration + + hours, minutes, seconds = map(int, time.split(":")) + return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) + + +@dataclass(order=True) +class VMScan: + """ + Data class to hold VM Scan data. + """ + + REF: str = field( + metadata={ + "description": "Scan reference ID. Formatted like scan/1234567890123456, compliance/1234567890123456, or qscap/1234567890123456." + }, + default=None, + ) + TYPE: Literal["On-Demand", "API", "Scheduled"] = field( + metadata={"description": "Type of scan. On-Demand, API, Scheduled."}, + default=None, + ) + TITLE: str = field(metadata={"description": "Title of the scan."}, default=None) + USER_LOGIN: str = field( + metadata={"description": "Owner of the scan."}, default=None + ) + LAUNCH_DATETIME: Union[str, datetime] = field( + metadata={"description": "Datetime the scan was launched."}, default=None + ) # post init to convert to datetime + DURATION: Union[str, timedelta] = field( + metadata={"description": "Duration of the scan."}, default=None + ) # post init to convert to timedelta + PROCESSING_PRIORITY: str = field( + metadata={"description": "Processing priority of the scan."}, default=None + ) + PROCESSED: bool = field( + metadata={"description": "Whether the scan has been processed."}, default=None + ) + STATUS: dict = field( + metadata={"description": "Status of the scan."}, default=None + ) # STATE is in this dict. get STATE in post init + STATE: str = field( + metadata={"description": "The state of the scan."}, init=False, default=None + ) # populated in post init + TARGET: Union[str, BaseList[str], BaseList[ip_address]] = field( + metadata={"description": "Target of the scan."}, default=None + ) + OPTION_PROFILE: dict = field( + metadata={"description": "Option profile of the scan."}, default=None + ) + ASSET_GROUP_TITLE_LIST: Union[str, BaseList[str]] = field( + metadata={"description": "Asset group title list."}, default=None + ) + + def __post_init__(self): + """ + Post init function to convert the LAUNCH_DATETIME and DURATION fields to datetime and timedelta objects. + """ + if self.LAUNCH_DATETIME: + self.LAUNCH_DATETIME = datetime.strptime( + self.LAUNCH_DATETIME, "%Y-%m-%dT%H:%M:%SZ" + ) + if self.DURATION: + self.DURATION = ( + parse_duration(self.DURATION) + if self.DURATION not in ["Pending", "Running"] + else self.DURATION + ) + + if self.STATUS: + self.STATE = self.STATUS["STATE"] + + # For the IPs/IP ranges in the TARGET field, determine if it is a single IP/range, or a comma separated string of IPs/ranges. + if self.TARGET: + final_list = BaseList() + self.TARGET = self.TARGET.split(",") + for t in self.TARGET: + if "-" in t: + t = single_range(t) + else: + t = single_ip(t) + final_list.append(t) + self.TARGET = final_list + + if self.PROCESSED: + self.PROCESSED = bool(self.PROCESSED) + + # create the baselist for the asset group titles + # first, check if [ASSET_GROUP_TITLE_LIST] is a dict. If it is, convert it to a list + if self.ASSET_GROUP_TITLE_LIST: + if isinstance(self.ASSET_GROUP_TITLE_LIST, dict): + self.ASSET_GROUP_TITLE_LIST = [self.ASSET_GROUP_TITLE_LIST] + # create the BaseList object + final_list = BaseList() + for ag in self.ASSET_GROUP_TITLE_LIST: + final_list.append(ag["ASSET_GROUP_TITLE"]) + self.ASSET_GROUP_TITLE_LIST = final_list + + def to_dict(self) -> dict: + """ + Convert the VMScan object to a dictionary. + """ + return asdict(self) + + @classmethod + def from_dict(cls, data: dict): + """ + Create a VMScan object from a dictionary. + """ + return cls(**data) + + def __str__(self): + return f"VMScan: {self.TITLE} - {self.REF}" + + def __int__(self): + return int(self.REF.split("/")[-1]) + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + + def items(self): + return self.__dict__.items() + + def valid_values(self): + return { + k: v + for k, v in self.__dict__.items() + if v is not None + and v != "" + and v != [] + and (isinstance(v, BaseList) and v != BaseList()) + } diff --git a/qualyspy/vmdr/get_host_list.py b/qualyspy/vmdr/get_host_list.py index d9851b8..18112ec 100644 --- a/qualyspy/vmdr/get_host_list.py +++ b/qualyspy/vmdr/get_host_list.py @@ -1,187 +1,187 @@ -""" -get_host_list.py - call the VMDR host list API. -""" - -from typing import Union - -# from xmltodict import parse -from urllib.parse import urlparse, parse_qs - -from ..base.call_api import call_api -from ..auth.token import BasicAuth -from ..exceptions.Exceptions import * -from .data_classes.hosts import VMDRHost, VMDRID -from .data_classes.lists.base_list import BaseList -from ..base import xml_parser - - -def get_host_list( - auth: BasicAuth, page_count: Union[int, "all"] = "all", **kwargs -) -> list: - """ - Get the host list from the VMDR API. - For a full list of parameters, see the Qualys API documentation: https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf - Args: - auth (BasicAuth): The authentication object. - page_count (Union[int, "all"]): The number of pages to get. If "all", get all pages. Defaults to "all". - API params (kwargs): - ``` - action (Optional[str]) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. - echo_request (Optional[bool]) #Whether to show the request. Default is 'False'. ends up being passed to API as 0 or 1. - show_asset_id (Optional[bool]) #Whether to show the asset IDs. Default is 'False'. ends up being passed to API as 0 or 1. - details (Optional[Union[Literal["Basic", "Basic/AGs", "All", "All/AGs", "None"], None]]): #The level of detail to return. Default is 'Basic'. Basic includes host ID, IP, tracking method, DNS, netBIOS, and OS. Basic/AGs includes basic host information plus asset group information. Asset group information includes the asset group ID and title. All shows all basic host information plus last vulnerability and compliance scan dates. All/AGs includes all information plus asset group information: group ID and title. None shows only the host ID (or asset ID if show_asset_id is set to 1 (True)). - os_pattern (Optional[str)] #The OS regex pattern to search for. It follows the PCRE standard, and must be URL-encoded. To match an empty string, use '%5E%24'. - truncation_limit (Optional[int]) #The maximum number of characters to return for each field. Default is 1000, and can be set to 0 for no truncation all the way up to 1,000,000. Past 1M, the API will return an error. - ips (Optional[str]) #Only show assets with certain IP addresses/ranges. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 10.0.0.1-10.0.0.255 - ipv6 (Optional[str]) #Only show assets with certain IPv6 addresses/ranges. Multiple values specified as a comma-separated string. - ag_ids (Optional[str]) #Only show assets in certain asset group IDs. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 1-5. A valid ID is required or the API will return an error. - ag_titles (Optional[str]) #Only show assets with specified string in the asset group title. Multiple values specified as a comma-separated string. - ids (Optional[str]) #Only show assets with certain asset IDs. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 1-5. A valid ID is required or the API will return an error. - id_min (Optional[int]) #Only show assets with asset IDs greater than or equal to this value. - id_max (Optional[int]) #Only show assets with asset IDs less than or equal to this value. - network_ids (Optional[str]) #Valid only when the Network Support feature is enabled for the users account). Restrict the request to certain custom network IDs. Multiple network IDs are comma separated. - compliance_enabled (Optional[bool)] #Only show assets with compliance enabled. Default is 'False'. ends up being passed to API as 0 or 1. - - '''DATE PARAMS''' - no_vm_scan_since (Optional[str]) #Only show assets that have not been scanned since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - no_compliance_scan_since (Optional[str]) #Only show assets that have not had a compliance scan since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_scan_since (Optional[str]) #Only show assets that have been scanned since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - compliance_scan_since (Optional[str]) #Only show assets that have had a compliance scan since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_processed_before (Optional[str]) #Only show assets that have been processed by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_processed_after (Optional[str]) #Only show assets that have been processed by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_scan_date_before (Optional[str]) #Only show assets that have been scanned by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_scan_date_after (Optional[str]) #Only show assets that have been scanned by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_auth_scan_date_before (Optional[str]) #Only show assets that have been authenticated scanned by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - vm_auth_scan_date_after (Optional[str]) #Only show assets that have been authenticated scanned by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - scap_scan_since (Optional[str]) #Only show assets that have been scanned by the SCAP module since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - no_scap_scan_since (Optional[str]) #Only show assets that have not been scanned by the SCAP module since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - - '''TAG PARAMS''' - use_tags (bool) #Whether to use tags. Default is 'False'. ends up being passed to API as 0 or 1. - tag_set_by (Optional[Literal["id","name"]]) #search for tags by ID or name. Default is 'id'. - tag_include_selector (Optional[Literal["any","all"]]) #Specified tags will be included if any or all are matched. Default is 'any'. Only valid if use_tags is set to 1 (True). - tag_exclude_selector (Optional[Literal["any","all"]]) #Specified tags will be excluded if any or all are matched. Default is 'any'. Only valid if use_tags is set to 1 (True). - tag_set_include (Optional[str]) #Only show assets with tags that match this string. Multiple values specified as a comma-separated string. Only valid if use_tags is set to 1 (True). - tag_set_exclude (Optional[str]) #Only show assets with tags that do not match this string. Multiple values specified as a comma-separated string. Only valid if use_tags is set to 1 (True). - show_tags (Optional[bool]) #Whether to show tags. Default is 'False'. ends up being passed to API as 0 or 1. - - '''ARS (Asset Risk Score) PARAMS''' - show_ars (Optional[bool]) #Whether to show the ARS. Default is 'False'. ends up being passed to API as 0 or 1. - ars_min (Optional[int]) #Only show assets with an ARS greater than or equal to this value. - ars_max (Optional[int]) #Only show assets with an ARS less than or equal to this value. - show_ars_factors (Optional[bool]) #Whether to show the ARS factors. Default is 'False'. ends up being passed to API as 0 or 1. - - '''TRURISK PARAMS''' - show_trurisk (Optional[bool]) #Whether to show the TruRisk. Default is 'False'. ends up being passed to API as 0 or 1. - trurisk_min (Optional[int]) #Only show assets with a TruRisk greater than or equal to this value. - trurisk_max (Optional[int]) #Only show assets with a TruRisk less than or equal to this value. - show_trurisk_factors (Optional[bool]) #Whether to show the TruRisk factors. Default is 'False'. ends up being passed to API as 0 or 1. - - '''CLOUD HOST PARAMS''' - host_metadata (Optional[Literal["all", "ec2", "azure", "google"]]) #Specify all to list all cloud and non-cloud assets. Specify a service to just get cloud assets for that service plus non-cloud assets. - host_metadata_fields (Optional[str]) #Specify the fields to return for cloud assets. Multiple values specified as a comma-separated string. Only valid if host_metadata is set to a cloud service. - show_cloud_tags (Optional[bool]) #Whether to show cloud tags. Default is 'False'. ends up being passed to API as 0 or 1. - cloud_tag_fields (Optional[str]) #Specify the cloud tag name and value, separated by a colon. Multiple values specified as a comma-separated string. If this is not specified and show_cloud_tags is 1, all tags are returned. - ``` - Returns: - list[Union[VMDRHost, VMDRID]]: A list of VMDRHost or VMDRID objects. - """ - - responses = BaseList() - pulled = 0 - - # add the action to the kwargs: - kwargs["action"] = "list" - - if kwargs.get("truncation_limit") and ( - kwargs["truncation_limit"] in [0, "0"] - and kwargs["details"] not in ["None", None] - ): - print( - "[!] Warning: You have specified to pull all data with no pagination. This is generally not recommended, as it uses lots of resources and take a long time to complete. Please consider specifying a page_count or truncation_limit to avoid this issue." - ) - - while True: - # make the request: - response = call_api( - auth=auth, - module="vmdr", - endpoint="get_host_list", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - if response.status_code != 200: - print("No data returned.") - return responses - - xml = xml_parser(response.content) - - if ( - "HOST_LIST" not in xml["HOST_LIST_OUTPUT"]["RESPONSE"] - and "ID_SET" not in xml["HOST_LIST_OUTPUT"]["RESPONSE"] - ): - print("No host list returned.") - return - - # If details is none, ID_SET will be returned instead of HOST_LIST - if "ID_SET" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]: - # check if ID_SET is a list of dicts or a single dict: - if isinstance(xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"], dict): - # if it's a single dict, convert it to a list of dicts: - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"] = [ - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"] - ] - - for ID in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"]: - # create a VMDRID object and append to responses - # This code will only run if details=None, so now we just need to check if show_asset_id is set to 1: - if kwargs.get("show_asset_id"): - responses.append(VMDRID(ID=ID, TYPE="asset")) - else: # elif not kwargs.get("show_asset_id"): - responses.append(VMDRID(ID=ID, TYPE="host")) - else: - # HOST_LIST will be returned - # first, check if xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] - # is a list of dicts or a single dict: - if isinstance( - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"], dict - ): - # if it's a single dict, convert it to a list of dicts: - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] = [ - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] - ] - - for host in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"]: - # return a list of VMDRHost objects - responses.append(VMDRHost.from_dict(host)) - - """( - print(f"Page {pulled+1} of {page_count} complete.") - if page_count != "all" - else print(f"Page {pulled+1} complete.") - )""" - pulled += 1 - - if page_count != "all" and pulled >= page_count: - print("Page count reached.") - break - - if "WARNING" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]: - if "URL" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["WARNING"]: - print( - f"Pagination detected. Pulling next page from url: {xml['HOST_LIST_OUTPUT']['RESPONSE']['WARNING']['URL']}" - ) - # get the id_min parameter from the URL to pass into kwargs: - params = parse_qs( - urlparse( - xml["HOST_LIST_OUTPUT"]["RESPONSE"]["WARNING"]["URL"] - ).query - ) - kwargs["id_min"] = params["id_min"][0] - - else: - break - else: - break - - return responses +""" +get_host_list.py - call the VMDR host list API. +""" + +from typing import Union + +# from xmltodict import parse +from urllib.parse import urlparse, parse_qs + +from ..base.call_api import call_api +from ..auth.token import BasicAuth +from ..exceptions.Exceptions import * +from .data_classes.hosts import VMDRHost, VMDRID +from .data_classes.lists.base_list import BaseList +from ..base import xml_parser + + +def get_host_list( + auth: BasicAuth, page_count: Union[int, "all"] = "all", **kwargs +) -> list: + """ + Get the host list from the VMDR API. + For a full list of parameters, see the Qualys API documentation: https://cdn2.qualys.com/docs/qualys-api-vmpc-user-guide.pdf + Params: + auth (BasicAuth): The authentication object. + page_count (Union[int, "all"]): The number of pages to get. If "all", get all pages. Defaults to "all". + API params (kwargs): + ``` + action (Optional[str]) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. + echo_request (Optional[bool]) #Whether to show the request. Default is 'False'. ends up being passed to API as 0 or 1. + show_asset_id (Optional[bool]) #Whether to show the asset IDs. Default is 'False'. ends up being passed to API as 0 or 1. + details (Optional[Union[Literal["Basic", "Basic/AGs", "All", "All/AGs", "None"], None]]): #The level of detail to return. Default is 'Basic'. Basic includes host ID, IP, tracking method, DNS, netBIOS, and OS. Basic/AGs includes basic host information plus asset group information. Asset group information includes the asset group ID and title. All shows all basic host information plus last vulnerability and compliance scan dates. All/AGs includes all information plus asset group information: group ID and title. None shows only the host ID (or asset ID if show_asset_id is set to 1 (True)). + os_pattern (Optional[str)] #The OS regex pattern to search for. It follows the PCRE standard, and must be URL-encoded. To match an empty string, use '%5E%24'. + truncation_limit (Optional[int]) #The maximum number of characters to return for each field. Default is 1000, and can be set to 0 for no truncation all the way up to 1,000,000. Past 1M, the API will return an error. + ips (Optional[str]) #Only show assets with certain IP addresses/ranges. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 10.0.0.1-10.0.0.255 + ipv6 (Optional[str]) #Only show assets with certain IPv6 addresses/ranges. Multiple values specified as a comma-separated string. + ag_ids (Optional[str]) #Only show assets in certain asset group IDs. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 1-5. A valid ID is required or the API will return an error. + ag_titles (Optional[str]) #Only show assets with specified string in the asset group title. Multiple values specified as a comma-separated string. + ids (Optional[str]) #Only show assets with certain asset IDs. Multiple values specified as a comma-separated string. A range is specified with a hyphen. Example: 1-5. A valid ID is required or the API will return an error. + id_min (Optional[int]) #Only show assets with asset IDs greater than or equal to this value. + id_max (Optional[int]) #Only show assets with asset IDs less than or equal to this value. + network_ids (Optional[str]) #Valid only when the Network Support feature is enabled for the users account). Restrict the request to certain custom network IDs. Multiple network IDs are comma separated. + compliance_enabled (Optional[bool)] #Only show assets with compliance enabled. Default is 'False'. ends up being passed to API as 0 or 1. + + '''DATE PARAMS''' + no_vm_scan_since (Optional[str]) #Only show assets that have not been scanned since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + no_compliance_scan_since (Optional[str]) #Only show assets that have not had a compliance scan since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_scan_since (Optional[str]) #Only show assets that have been scanned since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + compliance_scan_since (Optional[str]) #Only show assets that have had a compliance scan since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_processed_before (Optional[str]) #Only show assets that have been processed by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_processed_after (Optional[str]) #Only show assets that have been processed by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_scan_date_before (Optional[str]) #Only show assets that have been scanned by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_scan_date_after (Optional[str]) #Only show assets that have been scanned by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_auth_scan_date_before (Optional[str]) #Only show assets that have been authenticated scanned by the VM module before this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + vm_auth_scan_date_after (Optional[str]) #Only show assets that have been authenticated scanned by the VM module after this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + scap_scan_since (Optional[str]) #Only show assets that have been scanned by the SCAP module since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + no_scap_scan_since (Optional[str]) #Only show assets that have not been scanned by the SCAP module since this date. Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + + '''TAG PARAMS''' + use_tags (bool) #Whether to use tags. Default is 'False'. ends up being passed to API as 0 or 1. + tag_set_by (Optional[Literal["id","name"]]) #search for tags by ID or name. Default is 'id'. + tag_include_selector (Optional[Literal["any","all"]]) #Specified tags will be included if any or all are matched. Default is 'any'. Only valid if use_tags is set to 1 (True). + tag_exclude_selector (Optional[Literal["any","all"]]) #Specified tags will be excluded if any or all are matched. Default is 'any'. Only valid if use_tags is set to 1 (True). + tag_set_include (Optional[str]) #Only show assets with tags that match this string. Multiple values specified as a comma-separated string. Only valid if use_tags is set to 1 (True). + tag_set_exclude (Optional[str]) #Only show assets with tags that do not match this string. Multiple values specified as a comma-separated string. Only valid if use_tags is set to 1 (True). + show_tags (Optional[bool]) #Whether to show tags. Default is 'False'. ends up being passed to API as 0 or 1. + + '''ARS (Asset Risk Score) PARAMS''' + show_ars (Optional[bool]) #Whether to show the ARS. Default is 'False'. ends up being passed to API as 0 or 1. + ars_min (Optional[int]) #Only show assets with an ARS greater than or equal to this value. + ars_max (Optional[int]) #Only show assets with an ARS less than or equal to this value. + show_ars_factors (Optional[bool]) #Whether to show the ARS factors. Default is 'False'. ends up being passed to API as 0 or 1. + + '''TRURISK PARAMS''' + show_trurisk (Optional[bool]) #Whether to show the TruRisk. Default is 'False'. ends up being passed to API as 0 or 1. + trurisk_min (Optional[int]) #Only show assets with a TruRisk greater than or equal to this value. + trurisk_max (Optional[int]) #Only show assets with a TruRisk less than or equal to this value. + show_trurisk_factors (Optional[bool]) #Whether to show the TruRisk factors. Default is 'False'. ends up being passed to API as 0 or 1. + + '''CLOUD HOST PARAMS''' + host_metadata (Optional[Literal["all", "ec2", "azure", "google"]]) #Specify all to list all cloud and non-cloud assets. Specify a service to just get cloud assets for that service plus non-cloud assets. + host_metadata_fields (Optional[str]) #Specify the fields to return for cloud assets. Multiple values specified as a comma-separated string. Only valid if host_metadata is set to a cloud service. + show_cloud_tags (Optional[bool]) #Whether to show cloud tags. Default is 'False'. ends up being passed to API as 0 or 1. + cloud_tag_fields (Optional[str]) #Specify the cloud tag name and value, separated by a colon. Multiple values specified as a comma-separated string. If this is not specified and show_cloud_tags is 1, all tags are returned. + ``` + Returns: + list[Union[VMDRHost, VMDRID]]: A list of VMDRHost or VMDRID objects. + """ + + responses = BaseList() + pulled = 0 + + # add the action to the kwargs: + kwargs["action"] = "list" + + if kwargs.get("truncation_limit") and ( + kwargs["truncation_limit"] in [0, "0"] + and kwargs["details"] not in ["None", None] + ): + print( + "[!] Warning: You have specified to pull all data with no pagination. This is generally not recommended, as it uses lots of resources and take a long time to complete. Please consider specifying a page_count or truncation_limit to avoid this issue." + ) + + while True: + # make the request: + response = call_api( + auth=auth, + module="vmdr", + endpoint="get_host_list", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + if response.status_code != 200: + print("No data returned.") + return responses + + xml = xml_parser(response.content) + + if ( + "HOST_LIST" not in xml["HOST_LIST_OUTPUT"]["RESPONSE"] + and "ID_SET" not in xml["HOST_LIST_OUTPUT"]["RESPONSE"] + ): + print("No host list returned.") + return + + # If details is none, ID_SET will be returned instead of HOST_LIST + if "ID_SET" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]: + # check if ID_SET is a list of dicts or a single dict: + if isinstance(xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"], dict): + # if it's a single dict, convert it to a list of dicts: + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"] = [ + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"] + ] + + for ID in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["ID_SET"]["ID"]: + # create a VMDRID object and append to responses + # This code will only run if details=None, so now we just need to check if show_asset_id is set to 1: + if kwargs.get("show_asset_id"): + responses.append(VMDRID(ID=ID, TYPE="asset")) + else: # elif not kwargs.get("show_asset_id"): + responses.append(VMDRID(ID=ID, TYPE="host")) + else: + # HOST_LIST will be returned + # first, check if xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] + # is a list of dicts or a single dict: + if isinstance( + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"], dict + ): + # if it's a single dict, convert it to a list of dicts: + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] = [ + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] + ] + + for host in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"]: + # return a list of VMDRHost objects + responses.append(VMDRHost.from_dict(host)) + + """( + print(f"Page {pulled+1} of {page_count} complete.") + if page_count != "all" + else print(f"Page {pulled+1} complete.") + )""" + pulled += 1 + + if page_count != "all" and pulled >= page_count: + print("Page count reached.") + break + + if "WARNING" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]: + if "URL" in xml["HOST_LIST_OUTPUT"]["RESPONSE"]["WARNING"]: + print( + f"Pagination detected. Pulling next page from url: {xml['HOST_LIST_OUTPUT']['RESPONSE']['WARNING']['URL']}" + ) + # get the id_min parameter from the URL to pass into kwargs: + params = parse_qs( + urlparse( + xml["HOST_LIST_OUTPUT"]["RESPONSE"]["WARNING"]["URL"] + ).query + ) + kwargs["id_min"] = params["id_min"][0] + + else: + break + else: + break + + return responses diff --git a/qualyspy/vmdr/get_host_list_detections.py b/qualyspy/vmdr/get_host_list_detections.py index c7d7f90..9e7a373 100644 --- a/qualyspy/vmdr/get_host_list_detections.py +++ b/qualyspy/vmdr/get_host_list_detections.py @@ -1,439 +1,439 @@ -""" -get_host_list_detections.py - contains the get_host_list_detections function for the Qualyspy package. - -This endpoint is used to get a list of hosts and their QID detections. The function is multithreaded and uses the hld_backend function to pull the data. -""" - -from typing import List, Union -from urllib.parse import parse_qs, urlparse -from threading import current_thread, Thread, Lock -from queue import Queue, Empty -from os import cpu_count - -from .data_classes.hosts import VMDRHost, VMDRID -from .data_classes.lists.base_list import BaseList -from ..base.call_api import call_api -from ..auth.token import BasicAuth -from .get_host_list import ( - get_host_list, -) # Used to grab list of IDs for multithreaded detection list pulls -from ..exceptions.Exceptions import * -from ..base.xml_parser import xml_parser - -LOCK = Lock() - - -def normalize_id_list(id_list): - """ - normalize_id_list - formats the kwarg ids, if it is passed. - - Since the API accepts multiple values, either as comma-separated or as a range, this function - normalizes the input to a list of integers that satisfy the API requirements. - """ - id_list = id_list.split(",") - new_list = [] - for i in id_list: - if "-" in i: - # if there is a hyphen, split by hyphen and create a range - new_list.extend(range(int(i.split("-")[0]), int(i.split("-")[1]) + 1)) - else: - # if there is no hyphen, just append the integer - new_list.append(int(i)) - # Sort the list to ensure the range is correct - new_list.sort() - return new_list - - -def pull_id_set(auth: BasicAuth, ids: str = None) -> BaseList[VMDRID]: - """ - pull_id_set - pull a set of host IDs from the VMDR API. - - Args: - auth (BasicAuth): The BasicAuth object containing the username and password. - ids (str): A comma-separated string of host IDs to use. If specified, this will be used instead of pulling the full set. - - Returns: - List[int]: A BaseList of host IDs as VMDRIDs. - """ - if ids: - return [ - str(i) - for i in get_host_list(auth, details=None, truncation_limit=0, ids=ids) - ] - else: - return [str(i) for i in get_host_list(auth, details=None, truncation_limit=0)] - - -def hld_backend( - auth: BasicAuth, - page_count: Union[int, "all"] = "all", - **kwargs, -) -> List: - """ - hld_backend - get a list of hosts and their QID detections. - - Args: - auth (BasicAuth): The BasicAuth object containing the username and password. - page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". - **kwargs: Additional keyword arguments to pass to the API. See below. - - Keyword Args: - ``` - action (Optional[str]) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. - echo_request (Optional[bool]) #Whether to echo the request. Default is False. Ends up being passed to the API as 0 or 1. WARNING: this SDK does not include this field in the data. - show_asset_id (Optional[bool]) #Whether to show the asset IDs. Default is 'False'. ends up being passed to API as 0 or 1. - include_vuln_type (Optional[Literal["confirmed", "potential"]]) #The type of vulnerability to include. If not specified, both types are included. - - DETECTION FILTERS: - show_results (Optional[bool]) #Whether to show the results. Default is True. Ends up being passed to the API as 0 or 1. WARNING: this SDK overwrites any value you pass with '1'. It is just recognized as valid for the sake of completeness. - arf_kernel_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns for Linux kernels. 0 = don't filter, 1 = exclude kernel vulns that are not exploitable, 2 = only include kernel related vulns that are not exploitable, 3 = only include exploitable kernel vulns, 4 = only include kernel vulns. If specified, results are in a host's tag. - arf_service_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns found on running or nonrunning ports/services. 0 = don't filter, 1 = exclude service related vulns that are exploitable, 2 = only include service vulns that are exploitable, 3 = only include service vulns that are not exploitable, 4 = only include service vulns. If specified, results are in a host's tag. - arf_config_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns that can be vulnerable based on host config. 0 = don't filter, 1 = exclude vulns that are exploitable due to host config, 2 = only include config related vulns that are exploitable, 3 = only include config related vulns that are not exploitable, 4 = only include config related vulns. If specified, results are in a host's tag. - active_kernels_only (Optional[Literal[0,1,2,3]]) #Specify vulns related to running or non-running kernels. 0 = don't filter, 1 = exclude non-running kernels, 2 = only include vulns on non-running kernels, 3 = only include vulns with running kernels. If specified, results are in a host's tag. - output_format (Optional[Literal["XML", "CSV"]]) #The format of the output. Default is 'XML'. WARNING: this SDK will overwrite any value you pass with 'XML'. It is just recognized as valid for the sake of completeness. - supress_duplicated_data_from_csv (Optional[bool]) #Whether to suppress duplicated data from CSV. Default is False. Ends up being passed to the API as 0 or 1. WARNING: this SDK does not include this field in the data. - truncation_limit (Optional[int]) #The truncation limit for a page. Default is 1000. - max_days_since_detection_updated (Optional[int]) #The maximum number of days since the detection was last updated. For detections that have never changed, the value is applied to the last detection date. - detection_updated_since (Optional[str]) #The date and time since the detection was updated. - detection_updated_before (Optional[str]) #The date and time before the detection was updated. - detection_processed_before (Optional[str]) #The date and time before the detection was processed. - detection_processed_after (Optional[str]) #The date and time after the detection was processed. - detection_last_tested_since (Optional[str]) #The date and time since the detection was last tested. - detection_last_tested_since_days (Optional[int]) #The number of days since the detection was last tested. - detection_last_tested_before (Optional[str]) #The date and time before the detection was last tested. - detection_last_tested_before_days (Optional[int]) #The number of days before the detection was last tested. - include_ignored (Optional[bool]) #Whether to include ignored detections. Default is False. Ends up being passed to the API as 0 or 1. - include_disabled (Optional[bool]) #Whether to include disabled detections. Default is False. Ends up being passed to the API as 0 or 1. - - HOST FILTERS: - ids (Optional[str]) #A comma-separated string of host IDs to include. - id_min (Optional[Union[int,str]]) #The minimum host ID to include. - id_max (Optional[Union[int,str]]) #The maximum host ID to include. - ips (Optional[str]) #The IP address of the host to include. Can be a comma-separated string, and also supports ranges with a hyphen: 10.0.0.0-10.0.0.255. - ipv6 (Optional[str]) #The IPv6 address of the host to include. Can be a comma-separated string. Does not support ranges. - ag_ids (Optional[str]) #Show only hosts belonging to the specified asset group IDs. Can be a comma-separated string, and also supports ranges with a hyphen: 1-5. - ag_titles (Optional[str]) #Show only hosts belonging to the specified asset group titles. Can be a comma-separated string. - network_ids (Optional[str]) #Show only hosts belonging to the specified network IDs. Can be a comma-separated string. - network_names (Optional[str]) #displays the name of the network corresponding to the network ID. - vm_scan_since (Optional[str]) #The date and time since the last VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - no_vm_scan_since (Optional[str]) #The date and time since the last VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - max_days_since_last_vm_scan (Optional[int]) #The maximum number of days since the last VM scan. - vm_processed_before (Optional[str]) #The date and time before the VM scan was processed. Format is 'YYYY-MM-DD[THH:MM:SS]'. - vm_processed_after (Optional[str]) #The date and time after the VM scan was processed. Format is 'YYYY-MM-DD[THH:MM:SS]'. - vm_scan_date_before (Optional[str]) #The date and time before the VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - vm_scan_date_after (Optional[str]) #The date and time after the VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - vm_auth_scan_date_before (Optional[str]) #The date and time before the VM authenticated scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - vm_auth_scan_date_after (Optional[str]) #The date and time after the VM authenticated scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. - status (Optional[Literal["New", "Active", "Fixed", "Re-Opened"]]) #The status of the detection. - compliance_enabled (Optional[bool]) #Whether compliance is enabled. Default is False. Ends up being passed to the API as 0 or 1. - os_pattern (Optional[str]) #PCRE Regex to match operating systems. - - QID FILTERS: - qids (Optional[str]) #A comma-separated string of QIDs to include. - severities (Optional[str]) #A comma-separated string of severities to include. Can also be a hyphenated range, i.e. '2-4'. - filter_superseded_qids (Optional[bool]) #Whether to filter superseded QIDs. Default is False. Ends up being passed to the API as 0 or 1. - include_search_list_titles (Optional[str]) #A comma-separated string of search list titles to include. - exclude_search_list_titles (Optional[str]) #A comma-separated string of search list titles to exclude. - include_search_list_ids (Optional[str]) #A comma-separated string of search list IDs to include. - exclude_search_list_ids (Optional[str]) #A comma-separated string of search list IDs to exclude. - - ASSET TAG FILTERS: - use_tags (Optional[bool]) #Whether to use tags. Default is False. Ends up being passed to the API as 0 or 1. - tag_set_by (Optional[Literal['id','name']]) #When filtering on tags, whether to filter by tag ID or tag name. - tag_include_selector (Optional[Literal['any','all']]) #When filtering on tags, choose if asset has to match any or all tags specified. - tag_exclude_selector (Optional[Literal['any','all']]) #When filtering on tags, choose if asset has to match any or all tags specified. - tag_set_include (Optional[str]) #A comma-separated string of tag IDs or names to include. - tag_set_exclude (Optional[str]) #A comma-separated string of tag IDs or names to exclude. - show_tags (Optional[bool]) #Whether to show tags. Default is False. Ends up being passed to the API as 0 or 1. - - QDS FILTERS: - show_qds (Optional[bool]) #Whether to show QDS. Default is False. Ends up being passed to the API as 0 or 1. - qds_min (Optional[int]) #The minimum QDS to include. - qds_max (Optional[int]) #The maximum QDS to include. - show_qds_factors (Optional[bool]) #Whether to show QDS factors. Default is False. Ends up being passed to the API as 0 or 1. - - EC2/AZURE METADATA FILTERS: - host_metadata (Optional[Literal['all,'ec2', 'azure']]) #The type of metadata to include. Default is 'all'. - host_metadata_fields (Optional[str]) #A comma-separated string of metadata fields to include. Use carefully. - show_cloud_tags (Optional[bool]) #Whether to show cloud tags. Default is False. Ends up being passed to the API as 0 or 1. - cloud_tag_fields (Optional[str]) #A comma-separated string of cloud tag fields to include. Use carefully. - ``` - - - Returns: - List: A list of VMDRHost objects, with their DETECTIONS attribute populated. - """ - - # Set the kwargs - kwargs["action"] = "list" - kwargs["echo_request"] = 0 - kwargs["show_results"] = 1 - kwargs["output_format"] = "XML" - - responses = BaseList() - pulled = 0 - - while True: - with LOCK: - print( - f"{current_thread().name} - Pulling page {pulled+1} for ids {kwargs.get('ids')}. KWARGS: {kwargs}" - ) - - # make the request: - response = call_api( - auth=auth, - module="vmdr", - endpoint="get_hld", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - if response.status_code != 200: - with LOCK: - print(f"{current_thread().name} - No data returned on page {pulled}") - pulled += 1 - if pulled != "all": - if pulled == page_count: - print(f"{current_thread().name} - Pulled all pages.") - break - else: - continue - - # cleaned = remove_problem_characters(response.text) - xml = xml_parser(response.content) - - # check if there is no host list - if "HOST_LIST" not in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]: - with LOCK: - print(f"{current_thread().name} - No host list returned.") - else: - # check if ["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] is a list of dictionaries - # or just a dictionary. if it is just one, put it inside a list - if not isinstance( - xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"], - list, - ): - xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ - "HOST" - ] = [ - xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ - "HOST" - ] - ] - - for host in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ - "HOST" - ]: - host_obj = VMDRHost.from_dict(host) - responses.append(host_obj) - - pulled += 1 - if page_count != "all": - if pulled == page_count: - break - - if "WARNING" in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]: - if "URL" in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["WARNING"]: - # get the id_min parameter from the URL to pass into kwargs: - params = parse_qs( - urlparse( - xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["WARNING"][ - "URL" - ] - ).query - ) - with LOCK: - print( - f"{current_thread().name} - Pagination detected. Pulling next page with id_min: {params['id_min'][0]}" - ) - kwargs["id_min"] = params["id_min"][0] - - else: - break - else: - break - - return responses - - -def create_id_queue( - auth: BasicAuth, chunk_size: int = 100, ids: str = None -) -> BaseList: - """ - create_id_queue - create a queue of host IDs to pull. the queue contains chunks (lists) of chunk_size - length with host IDs. - - Args: - auth (BasicAuth): The BasicAuth object containing the username and password. - chunk_size (int): The size of each chunk. Defaults to 100. - ids (str): A comma-separated string of host IDs to use. If specified, this will be used instead of pulling the full set. - - Returns: - Queue: A queue of host IDs to pull. - """ - - if ids: - id_list = pull_id_set(auth, ids=ids) - else: - id_list = pull_id_set(auth) - - if not id_list: - raise QualysAPIError("No IDs returned from API.") - - print(f"ID set pulled. Total IDs: {len(id_list)}") - - id_queue = Queue() - - for i in range(0, len(id_list), chunk_size): - id_queue.put(id_list[i : i + chunk_size]) - - singular_chunk = True if id_queue.qsize() == 1 else False - - print( - f"Queue created with {id_queue.qsize()} {'chunks' if not singular_chunk else 'chunk'} of ~{chunk_size} IDs{' each.' if not singular_chunk else '.'}" - ) - - return id_queue - - -def get_hld( - auth: BasicAuth, - chunk_size: int = 3000, - threads: int = 5, - page_count: Union[int, "all"] = "all", - chunk_count: Union[int, "all"] = "all", - **kwargs, -) -> List: - """ - get_hld - get a list of hosts and their QID detections using multiple threads. Read - hld_backend for more information on the kwargs. - - Args: - auth (BasicAuth): The BasicAuth object containing the username and password. - chunk_size (int): The size of each chunk. Defaults to 3000. - threads (int): The number of threads to use. Defaults to 5. - page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". - chunk_count (Union[int, "all"]): The number of chunks to retrieve. Defaults to "all". - **kwargs: Additional keyword arguments to pass to the API. See qualyspy.vmdr.get_host_list_detections.hld_backend() for details. - - Returns: - BaseList: A list of VMDRHost objects, with their DETECTIONS attribute populated. - """ - - # Ensure that threads, chunk_size, and page_count (if not 'all') are all integers above 0 - if any( - [ - threads < 1, - chunk_size < 1, - (page_count != "all" and page_count < 1), - (chunk_count != "all" and chunk_count < 1), - ] - ): - raise ValueError( - "threads, chunk_size, page_count (if not 'all') and chunk_count (if not 'all') must all be integers above 0." - ) - - # Make sure the user hasn't set threads to more than the cpu count - if threads > cpu_count(): - print( - f"Warning: The number of threads ({threads}) is greater than the number of CPUs ({cpu_count()}). This may cause performance issues." - ) - - # Second, get concurrency rate limit from auth object. NOTE: eventually, auth class should have an attribute for this. - rl = auth.get_ratelimit() - if threads > rl["X-Concurrency-Limit-Limit"]: - print( - f"Warning: The number of threads ({threads}) is greater than the concurrency rate limit ({rl['X-Concurrency-Limit-Limit']}). Setting threads to {rl['X-Concurrency-Limit-Limit']}." - ) - threads = rl["X-Concurrency-Limit-Limit"] - - ( - print(f"Pulling/creating queue for full ID list...") - if not kwargs.get("ids") - else print( - f"Pulling/creating queue for user-specified IDs: {kwargs.get('ids')}..." - ) - ) - - id_queue = create_id_queue(auth, chunk_size=chunk_size, ids=kwargs.get("ids")) - print(f"Starting get_hld with {threads} {'threads.' if threads > 1 else 'thread.'}") - - threads_list = [] - - responses = BaseList() - - for i in range(threads): - thread = Thread( - target=threaded_hld_worker, - args=(auth, id_queue, responses, page_count, chunk_count, kwargs), - ) - threads_list.append(thread) - thread.start() - - for thread in threads_list: - thread.join() - - print("All threads have completed. Returning responses.") - return responses - - -def threaded_hld_worker( - auth: BasicAuth, - id_queue: Queue, - responses: BaseList, - page_count: Union[int, "all"], - chunk_count: Union[int, "all"], - kwargs, -): - """ - threaded_hld_worker - the worker function for get_hld/hld_backend functions. - - Args: - auth (BasicAuth): The BasicAuth object containing the username and password. - id_queue (Queue): The queue of host IDs to pull. - responses (BaseList): The list of responses to append to. - page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". - chunk_count (Union[int, "all"]): The number of chunks to retrieve. Defaults to "all". - **kwargs: Additional keyword arguments to pass to the API. See get_hld() for details. - """ - while True: - pages_pulled = 0 - chunks_pulled = 0 - try: - ids = ( - id_queue.get_nowait() - ) # nowait allows us to check if the queue is empty without blocking - except Empty: - with LOCK: - print(f"{current_thread().name} - Queue is empty. Terminating thread.") - break - - if not ids: - with LOCK: - print(f"{current_thread().name} - No IDs to pull. Terminating thread.") - break - - kwargs["ids"] = f"{ids[0]}-{ids[-1]}" - responses.extend(hld_backend(auth, page_count=page_count, **kwargs)) - id_queue.task_done() - with LOCK: - print(f"{current_thread().name} - Chunk complete.") - pages_pulled += 1 - chunks_pulled += 1 - # check if the queue is empty, or if the threads are done (via pulled var) - if id_queue.empty(): - with LOCK: - print(f"{current_thread().name} - Queue is empty. Terminating thread.") - break - if pages_pulled == page_count: - with LOCK: - print( - f"{current_thread().name} - Thread has pulled all pages. Terminating thread." - ) - break - if chunks_pulled == chunk_count: - with LOCK: - print( - f"{current_thread().name} - Thread has pulled all chunks. Terminating thread." - ) - break +""" +get_host_list_detections.py - contains the get_host_list_detections function for the Qualyspy package. + +This endpoint is used to get a list of hosts and their QID detections. The function is multithreaded and uses the hld_backend function to pull the data. +""" + +from typing import List, Union +from urllib.parse import parse_qs, urlparse +from threading import current_thread, Thread, Lock +from queue import Queue, Empty +from os import cpu_count + +from .data_classes.hosts import VMDRHost, VMDRID +from .data_classes.lists.base_list import BaseList +from ..base.call_api import call_api +from ..auth.token import BasicAuth +from .get_host_list import ( + get_host_list, +) # Used to grab list of IDs for multithreaded detection list pulls +from ..exceptions.Exceptions import * +from ..base.xml_parser import xml_parser + +LOCK = Lock() + + +def normalize_id_list(id_list): + """ + normalize_id_list - formats the kwarg ids, if it is passed. + + Since the API accepts multiple values, either as comma-separated or as a range, this function + normalizes the input to a list of integers that satisfy the API requirements. + """ + id_list = id_list.split(",") + new_list = [] + for i in id_list: + if "-" in i: + # if there is a hyphen, split by hyphen and create a range + new_list.extend(range(int(i.split("-")[0]), int(i.split("-")[1]) + 1)) + else: + # if there is no hyphen, just append the integer + new_list.append(int(i)) + # Sort the list to ensure the range is correct + new_list.sort() + return new_list + + +def pull_id_set(auth: BasicAuth, ids: str = None) -> BaseList[VMDRID]: + """ + pull_id_set - pull a set of host IDs from the VMDR API. + + Params: + auth (BasicAuth): The BasicAuth object containing the username and password. + ids (str): A comma-separated string of host IDs to use. If specified, this will be used instead of pulling the full set. + + Returns: + List[int]: A BaseList of host IDs as VMDRIDs. + """ + if ids: + return [ + str(i) + for i in get_host_list(auth, details=None, truncation_limit=0, ids=ids) + ] + else: + return [str(i) for i in get_host_list(auth, details=None, truncation_limit=0)] + + +def hld_backend( + auth: BasicAuth, + page_count: Union[int, "all"] = "all", + **kwargs, +) -> List: + """ + hld_backend - get a list of hosts and their QID detections. + + Params: + auth (BasicAuth): The BasicAuth object containing the username and password. + page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". + **kwargs: Additional keyword arguments to pass to the API. See below. + + Keyword Args: + ``` + action (Optional[str]) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. + echo_request (Optional[bool]) #Whether to echo the request. Default is False. Ends up being passed to the API as 0 or 1. WARNING: this SDK does not include this field in the data. + show_asset_id (Optional[bool]) #Whether to show the asset IDs. Default is 'False'. ends up being passed to API as 0 or 1. + include_vuln_type (Optional[Literal["confirmed", "potential"]]) #The type of vulnerability to include. If not specified, both types are included. + + DETECTION FILTERS: + show_results (Optional[bool]) #Whether to show the results. Default is True. Ends up being passed to the API as 0 or 1. WARNING: this SDK overwrites any value you pass with '1'. It is just recognized as valid for the sake of completeness. + arf_kernel_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns for Linux kernels. 0 = don't filter, 1 = exclude kernel vulns that are not exploitable, 2 = only include kernel related vulns that are not exploitable, 3 = only include exploitable kernel vulns, 4 = only include kernel vulns. If specified, results are in a host's tag. + arf_service_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns found on running or nonrunning ports/services. 0 = don't filter, 1 = exclude service related vulns that are exploitable, 2 = only include service vulns that are exploitable, 3 = only include service vulns that are not exploitable, 4 = only include service vulns. If specified, results are in a host's tag. + arf_config_filter (Optional[Literal[0,1,2,3,4]]) #Specify vulns that can be vulnerable based on host config. 0 = don't filter, 1 = exclude vulns that are exploitable due to host config, 2 = only include config related vulns that are exploitable, 3 = only include config related vulns that are not exploitable, 4 = only include config related vulns. If specified, results are in a host's tag. + active_kernels_only (Optional[Literal[0,1,2,3]]) #Specify vulns related to running or non-running kernels. 0 = don't filter, 1 = exclude non-running kernels, 2 = only include vulns on non-running kernels, 3 = only include vulns with running kernels. If specified, results are in a host's tag. + output_format (Optional[Literal["XML", "CSV"]]) #The format of the output. Default is 'XML'. WARNING: this SDK will overwrite any value you pass with 'XML'. It is just recognized as valid for the sake of completeness. + supress_duplicated_data_from_csv (Optional[bool]) #Whether to suppress duplicated data from CSV. Default is False. Ends up being passed to the API as 0 or 1. WARNING: this SDK does not include this field in the data. + truncation_limit (Optional[int]) #The truncation limit for a page. Default is 1000. + max_days_since_detection_updated (Optional[int]) #The maximum number of days since the detection was last updated. For detections that have never changed, the value is applied to the last detection date. + detection_updated_since (Optional[str]) #The date and time since the detection was updated. + detection_updated_before (Optional[str]) #The date and time before the detection was updated. + detection_processed_before (Optional[str]) #The date and time before the detection was processed. + detection_processed_after (Optional[str]) #The date and time after the detection was processed. + detection_last_tested_since (Optional[str]) #The date and time since the detection was last tested. + detection_last_tested_since_days (Optional[int]) #The number of days since the detection was last tested. + detection_last_tested_before (Optional[str]) #The date and time before the detection was last tested. + detection_last_tested_before_days (Optional[int]) #The number of days before the detection was last tested. + include_ignored (Optional[bool]) #Whether to include ignored detections. Default is False. Ends up being passed to the API as 0 or 1. + include_disabled (Optional[bool]) #Whether to include disabled detections. Default is False. Ends up being passed to the API as 0 or 1. + + HOST FILTERS: + ids (Optional[str]) #A comma-separated string of host IDs to include. + id_min (Optional[Union[int,str]]) #The minimum host ID to include. + id_max (Optional[Union[int,str]]) #The maximum host ID to include. + ips (Optional[str]) #The IP address of the host to include. Can be a comma-separated string, and also supports ranges with a hyphen: 10.0.0.0-10.0.0.255. + ipv6 (Optional[str]) #The IPv6 address of the host to include. Can be a comma-separated string. Does not support ranges. + ag_ids (Optional[str]) #Show only hosts belonging to the specified asset group IDs. Can be a comma-separated string, and also supports ranges with a hyphen: 1-5. + ag_titles (Optional[str]) #Show only hosts belonging to the specified asset group titles. Can be a comma-separated string. + network_ids (Optional[str]) #Show only hosts belonging to the specified network IDs. Can be a comma-separated string. + network_names (Optional[str]) #displays the name of the network corresponding to the network ID. + vm_scan_since (Optional[str]) #The date and time since the last VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + no_vm_scan_since (Optional[str]) #The date and time since the last VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + max_days_since_last_vm_scan (Optional[int]) #The maximum number of days since the last VM scan. + vm_processed_before (Optional[str]) #The date and time before the VM scan was processed. Format is 'YYYY-MM-DD[THH:MM:SS]'. + vm_processed_after (Optional[str]) #The date and time after the VM scan was processed. Format is 'YYYY-MM-DD[THH:MM:SS]'. + vm_scan_date_before (Optional[str]) #The date and time before the VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + vm_scan_date_after (Optional[str]) #The date and time after the VM scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + vm_auth_scan_date_before (Optional[str]) #The date and time before the VM authenticated scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + vm_auth_scan_date_after (Optional[str]) #The date and time after the VM authenticated scan. Format is 'YYYY-MM-DD[THH:MM:SS]'. + status (Optional[Literal["New", "Active", "Fixed", "Re-Opened"]]) #The status of the detection. + compliance_enabled (Optional[bool]) #Whether compliance is enabled. Default is False. Ends up being passed to the API as 0 or 1. + os_pattern (Optional[str]) #PCRE Regex to match operating systems. + + QID FILTERS: + qids (Optional[str]) #A comma-separated string of QIDs to include. + severities (Optional[str]) #A comma-separated string of severities to include. Can also be a hyphenated range, i.e. '2-4'. + filter_superseded_qids (Optional[bool]) #Whether to filter superseded QIDs. Default is False. Ends up being passed to the API as 0 or 1. + include_search_list_titles (Optional[str]) #A comma-separated string of search list titles to include. + exclude_search_list_titles (Optional[str]) #A comma-separated string of search list titles to exclude. + include_search_list_ids (Optional[str]) #A comma-separated string of search list IDs to include. + exclude_search_list_ids (Optional[str]) #A comma-separated string of search list IDs to exclude. + + ASSET TAG FILTERS: + use_tags (Optional[bool]) #Whether to use tags. Default is False. Ends up being passed to the API as 0 or 1. + tag_set_by (Optional[Literal['id','name']]) #When filtering on tags, whether to filter by tag ID or tag name. + tag_include_selector (Optional[Literal['any','all']]) #When filtering on tags, choose if asset has to match any or all tags specified. + tag_exclude_selector (Optional[Literal['any','all']]) #When filtering on tags, choose if asset has to match any or all tags specified. + tag_set_include (Optional[str]) #A comma-separated string of tag IDs or names to include. + tag_set_exclude (Optional[str]) #A comma-separated string of tag IDs or names to exclude. + show_tags (Optional[bool]) #Whether to show tags. Default is False. Ends up being passed to the API as 0 or 1. + + QDS FILTERS: + show_qds (Optional[bool]) #Whether to show QDS. Default is False. Ends up being passed to the API as 0 or 1. + qds_min (Optional[int]) #The minimum QDS to include. + qds_max (Optional[int]) #The maximum QDS to include. + show_qds_factors (Optional[bool]) #Whether to show QDS factors. Default is False. Ends up being passed to the API as 0 or 1. + + EC2/AZURE METADATA FILTERS: + host_metadata (Optional[Literal['all,'ec2', 'azure']]) #The type of metadata to include. Default is 'all'. + host_metadata_fields (Optional[str]) #A comma-separated string of metadata fields to include. Use carefully. + show_cloud_tags (Optional[bool]) #Whether to show cloud tags. Default is False. Ends up being passed to the API as 0 or 1. + cloud_tag_fields (Optional[str]) #A comma-separated string of cloud tag fields to include. Use carefully. + ``` + + + Returns: + List: A list of VMDRHost objects, with their DETECTIONS attribute populated. + """ + + # Set the kwargs + kwargs["action"] = "list" + kwargs["echo_request"] = 0 + kwargs["show_results"] = 1 + kwargs["output_format"] = "XML" + + responses = BaseList() + pulled = 0 + + while True: + with LOCK: + print( + f"{current_thread().name} - Pulling page {pulled+1} for ids {kwargs.get('ids')}. KWARGS: {kwargs}" + ) + + # make the request: + response = call_api( + auth=auth, + module="vmdr", + endpoint="get_hld", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + if response.status_code != 200: + with LOCK: + print(f"{current_thread().name} - No data returned on page {pulled}") + pulled += 1 + if pulled != "all": + if pulled == page_count: + print(f"{current_thread().name} - Pulled all pages.") + break + else: + continue + + # cleaned = remove_problem_characters(response.text) + xml = xml_parser(response.content) + + # check if there is no host list + if "HOST_LIST" not in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]: + with LOCK: + print(f"{current_thread().name} - No host list returned.") + else: + # check if ["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"] is a list of dictionaries + # or just a dictionary. if it is just one, put it inside a list + if not isinstance( + xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"]["HOST"], + list, + ): + xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ + "HOST" + ] = [ + xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ + "HOST" + ] + ] + + for host in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["HOST_LIST"][ + "HOST" + ]: + host_obj = VMDRHost.from_dict(host) + responses.append(host_obj) + + pulled += 1 + if page_count != "all": + if pulled == page_count: + break + + if "WARNING" in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]: + if "URL" in xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["WARNING"]: + # get the id_min parameter from the URL to pass into kwargs: + params = parse_qs( + urlparse( + xml["HOST_LIST_VM_DETECTION_OUTPUT"]["RESPONSE"]["WARNING"][ + "URL" + ] + ).query + ) + with LOCK: + print( + f"{current_thread().name} - Pagination detected. Pulling next page with id_min: {params['id_min'][0]}" + ) + kwargs["id_min"] = params["id_min"][0] + + else: + break + else: + break + + return responses + + +def create_id_queue( + auth: BasicAuth, chunk_size: int = 100, ids: str = None +) -> BaseList: + """ + create_id_queue - create a queue of host IDs to pull. the queue contains chunks (lists) of chunk_size + length with host IDs. + + Params: + auth (BasicAuth): The BasicAuth object containing the username and password. + chunk_size (int): The size of each chunk. Defaults to 100. + ids (str): A comma-separated string of host IDs to use. If specified, this will be used instead of pulling the full set. + + Returns: + Queue: A queue of host IDs to pull. + """ + + if ids: + id_list = pull_id_set(auth, ids=ids) + else: + id_list = pull_id_set(auth) + + if not id_list: + raise QualysAPIError("No IDs returned from API.") + + print(f"ID set pulled. Total IDs: {len(id_list)}") + + id_queue = Queue() + + for i in range(0, len(id_list), chunk_size): + id_queue.put(id_list[i : i + chunk_size]) + + singular_chunk = True if id_queue.qsize() == 1 else False + + print( + f"Queue created with {id_queue.qsize()} {'chunks' if not singular_chunk else 'chunk'} of ~{chunk_size} IDs{' each.' if not singular_chunk else '.'}" + ) + + return id_queue + + +def get_hld( + auth: BasicAuth, + chunk_size: int = 3000, + threads: int = 5, + page_count: Union[int, "all"] = "all", + chunk_count: Union[int, "all"] = "all", + **kwargs, +) -> List: + """ + get_hld - get a list of hosts and their QID detections using multiple threads. Read + hld_backend for more information on the kwargs. + + Params: + auth (BasicAuth): The BasicAuth object containing the username and password. + chunk_size (int): The size of each chunk. Defaults to 3000. + threads (int): The number of threads to use. Defaults to 5. + page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". + chunk_count (Union[int, "all"]): The number of chunks to retrieve. Defaults to "all". + **kwargs: Additional keyword arguments to pass to the API. See qualyspy.vmdr.get_host_list_detections.hld_backend() for details. + + Returns: + BaseList: A list of VMDRHost objects, with their DETECTIONS attribute populated. + """ + + # Ensure that threads, chunk_size, and page_count (if not 'all') are all integers above 0 + if any( + [ + threads < 1, + chunk_size < 1, + (page_count != "all" and page_count < 1), + (chunk_count != "all" and chunk_count < 1), + ] + ): + raise ValueError( + "threads, chunk_size, page_count (if not 'all') and chunk_count (if not 'all') must all be integers above 0." + ) + + # Make sure the user hasn't set threads to more than the cpu count + if threads > cpu_count(): + print( + f"Warning: The number of threads ({threads}) is greater than the number of CPUs ({cpu_count()}). This may cause performance issues." + ) + + # Second, get concurrency rate limit from auth object. NOTE: eventually, auth class should have an attribute for this. + rl = auth.get_ratelimit() + if threads > rl["X-Concurrency-Limit-Limit"]: + print( + f"Warning: The number of threads ({threads}) is greater than the concurrency rate limit ({rl['X-Concurrency-Limit-Limit']}). Setting threads to {rl['X-Concurrency-Limit-Limit']}." + ) + threads = rl["X-Concurrency-Limit-Limit"] + + ( + print(f"Pulling/creating queue for full ID list...") + if not kwargs.get("ids") + else print( + f"Pulling/creating queue for user-specified IDs: {kwargs.get('ids')}..." + ) + ) + + id_queue = create_id_queue(auth, chunk_size=chunk_size, ids=kwargs.get("ids")) + print(f"Starting get_hld with {threads} {'threads.' if threads > 1 else 'thread.'}") + + threads_list = [] + + responses = BaseList() + + for i in range(threads): + thread = Thread( + target=threaded_hld_worker, + args=(auth, id_queue, responses, page_count, chunk_count, kwargs), + ) + threads_list.append(thread) + thread.start() + + for thread in threads_list: + thread.join() + + print("All threads have completed. Returning responses.") + return responses + + +def threaded_hld_worker( + auth: BasicAuth, + id_queue: Queue, + responses: BaseList, + page_count: Union[int, "all"], + chunk_count: Union[int, "all"], + kwargs, +): + """ + threaded_hld_worker - the worker function for get_hld/hld_backend functions. + + Params: + auth (BasicAuth): The BasicAuth object containing the username and password. + id_queue (Queue): The queue of host IDs to pull. + responses (BaseList): The list of responses to append to. + page_count (Union[int, "all"]): The number of pages to retrieve. Defaults to "all". + chunk_count (Union[int, "all"]): The number of chunks to retrieve. Defaults to "all". + **kwargs: Additional keyword arguments to pass to the API. See get_hld() for details. + """ + while True: + pages_pulled = 0 + chunks_pulled = 0 + try: + ids = ( + id_queue.get_nowait() + ) # nowait allows us to check if the queue is empty without blocking + except Empty: + with LOCK: + print(f"{current_thread().name} - Queue is empty. Terminating thread.") + break + + if not ids: + with LOCK: + print(f"{current_thread().name} - No IDs to pull. Terminating thread.") + break + + kwargs["ids"] = f"{ids[0]}-{ids[-1]}" + responses.extend(hld_backend(auth, page_count=page_count, **kwargs)) + id_queue.task_done() + with LOCK: + print(f"{current_thread().name} - Chunk complete.") + pages_pulled += 1 + chunks_pulled += 1 + # check if the queue is empty, or if the threads are done (via pulled var) + if id_queue.empty(): + with LOCK: + print(f"{current_thread().name} - Queue is empty. Terminating thread.") + break + if pages_pulled == page_count: + with LOCK: + print( + f"{current_thread().name} - Thread has pulled all pages. Terminating thread." + ) + break + if chunks_pulled == chunk_count: + with LOCK: + print( + f"{current_thread().name} - Thread has pulled all chunks. Terminating thread." + ) + break diff --git a/qualyspy/vmdr/ips.py b/qualyspy/vmdr/ips.py index 83bc4e2..472710d 100644 --- a/qualyspy/vmdr/ips.py +++ b/qualyspy/vmdr/ips.py @@ -1,204 +1,204 @@ -""" -ips.py - IP address manipulation from Qualys subscription and stores them in a BaseList object. -""" - -from typing import Union - -from ..base.call_api import call_api -from ..auth.token import BasicAuth -from ..exceptions.Exceptions import * -from .data_classes.ip_converters import convert_ips, convert_ranges -from .data_classes.lists.base_list import BaseList -from ..base import xml_parser - - -def get_ip_list(auth: BasicAuth, **kwargs) -> BaseList: - """ - Gets a list of IP addresses from the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - - Keyword Args: - ``` - action (str): Action to perform on the IP addresses. Defaults to "list". WARNING: SDK automatically sets this value. It is just included for completeness. - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. - ips (str): Show only certain IP addresses/ranges. One or more IPs/ranges may be specified. Multiple entries are comma separated. A host IP range is specified with a hyphen (for example, 10.10.10.44-10.10.10.90). - network_id (Union[str, int]): Network ID to filter on. Defaults to None. NOTE: This has to be enabled in the Qualys subscription! - tracking_method (Literal[None, "IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to None. Valid values are IP, DNS, NETBIOS. - compliance_enabled (Union[str, bool]): Whether to filter to IPs within the compliance module. Defaults to None. - certview_enabled (Union[str, bool]): Whether to filter to IPs within certview module. Defaults to None. - - Returns: - BaseList: BaseList object containing the IP addresses/ranges. - """ - ip_list = BaseList() - - kwargs["action"] = "list" - kwargs["echo_request"] = False - - response = call_api( - auth=auth, - module="vmdr", - endpoint="get_ip_list", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - if response.status_code == 200: - data = xml_parser(response.text)["IP_LIST_OUTPUT"] - - if "IP_SET" not in data["RESPONSE"]: - print("No IP addresses found. Returning empty BaseList.") - return ip_list - - data = data["RESPONSE"][ - "IP_SET" - ] # at this point, data has IP and IP_RANGE keys - - # Convert the IP addresses into IP objects: - if "IP" in data: - # Normalize, account for a str: - if isinstance(data["IP"], str): - data["IP"] = [data["IP"]] - single_ips = [ip for ip in data["IP"]] # Single IPs - ip_list.extend(convert_ips(single_ips)) - - # Convert the IP ranges into IPNetwork objects: - if "IP_RANGE" in data: - # Normalize, account for a str: - if isinstance(data["IP_RANGE"], str): - data["IP_RANGE"] = [data["IP_RANGE"]] - range_ips = [ip for ip in data["IP_RANGE"]] # IP Ranges - ip_list.extend(convert_ranges(range_ips)) - - else: - raise Exception(f"Failed to pull IP list. Status code: {response.status_code}") - - return ip_list - - -def add_ips( - auth: BasicAuth, - ips: Union[str, BaseList], - enable_pc: Union[bool, int] = False, - enable_vm: Union[bool, int] = True, - enable_sca: Union[bool, int] = False, - **kwargs, -) -> None: - """ - Adds IP addresses to the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - ips (Union[str, BaseList): List of IP addresses/ranges to add. Can be a string or a BaseList object containing IPs. Multiple entries are comma separated. A host IP range is specified with a hyphen. - enable_pc (Union[bool, int]): Whether to enable policy compliance tracking on the IP addresses. Defaults to False. - enable_vm (Union[bool, int]): Whether to enable vulnerability management tracking on the IP addresses. Defaults to True. - enable_sca (bool): Whether to enable SCA on the IP addresses. Defaults to False. - NOTE: EITHER enable_pc OR enable_vm MUST BE TRUE FOR THE IP ADDITION TO WORK! - - Keyword Args: - ``` - action (str): Action to perform on the IP addresses. Defaults to "add". WARNING: SDK automatically sets this value. It is just included for completeness. - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. - tracking_method (Literal["IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to "IP". Valid values are IP, DNS, NETBIOS. - owner (str): Owner of the IP addresses. Defaults to None. - ud1 (str): User-defined field 1. Defaults to None. - ud2 (str): User-defined field 2. Defaults to None. - ud3 (str): User-defined field 3. Defaults to None. - comment (str): Comment for the IP addresses. Defaults to None. - ag_title (str): Asset group title to add the IP addresses to. Defaults to None. Required if user is a Unit Manager. - enable_certview (bool): Whether to enable CertView on the IP addresses. Defaults to False. - ``` - - Returns: - None - """ - - # Check if either enable_pc or enable_vm is True: - if not enable_pc and not enable_vm: - raise ValueError("Either enable_pc or enable_vm must be True!") - - # Check tracking_method: - if "tracking_method" in kwargs and kwargs["tracking_method"] not in [ - "IP", - "DNS", - "NETBIOS", - ]: - raise ValueError( - f"Invalid tracking method. Valid values are IP, DNS, NETBIOS, not {kwargs['tracking_method']}." - ) - - kwargs["action"] = "add" - kwargs["echo_request"] = False - - # If ips is a BaseList object, convert it to a list of strings: - if isinstance(ips, BaseList): - ips = [str(ip) for ip in ips] - ips = ",".join(ips) - - kwargs["ips"] = ips - kwargs["enable_pc"] = enable_pc - kwargs["enable_vm"] = enable_vm - kwargs["enable_sca"] = enable_sca - - response = call_api( - auth=auth, - module="vmdr", - endpoint="add_ips", - payload=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] - - print(result) - - -def update_ips(auth: BasicAuth, ips: Union[str, BaseList], **kwargs) -> None: - """ - Update specific details of IP addresses in the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - ips (Union[str, BaseList): List of IP addresses/ranges to update. Can be a string or a BaseList object containing IPs. Multiple entries are comma separated. A host IP range is specified with a hyphen. - - Keyword Args: - ``` - action (str): Action to perform on the IP addresses. Defaults to "update". WARNING: SDK automatically sets this value. It is just included for completeness. - echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. - network_id (Union[str, int]): Network ID to filter on. Defaults to None. NOTE: This has to be enabled in the Qualys subscription! - tracking_method (Literal["IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to "IP". Valid values are IP, DNS, NETBIOS. - host_dns (str): The DNS hostname for the IP you want to update. A single IP must be specified in the same request and the IP will only be updated if it matches the hostname specified. - host_netbios (str): The NetBIOS hostname for the IP you want to update. A single IP must be specified in the same request and the IP will only be updated if it matches the hostname specified. - owner (str): Owner of the IP addresses. Defaults to None. - ud1 (str): User-defined field 1. Defaults to None. - ud2 (str): User-defined field 2. Defaults to None. - ud3 (str): User-defined field 3. Defaults to None. - comment (str): Comment for the IP addresses. Defaults to None. - - Returns: - None - """ - - kwargs["action"] = "update" - kwargs["echo_request"] = False - - # If ips is a BaseList object, convert it to a list of strings: - if isinstance(ips, BaseList): - ips = [str(ip) for ip in ips] - ips = ",".join(ips) - - kwargs["ips"] = ips - - response = call_api( - auth=auth, - module="vmdr", - endpoint="update_ips", - payload=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] - - print(result) +""" +ips.py - IP address manipulation from Qualys subscription and stores them in a BaseList object. +""" + +from typing import Union + +from ..base.call_api import call_api +from ..auth.token import BasicAuth +from ..exceptions.Exceptions import * +from .data_classes.ip_converters import convert_ips, convert_ranges +from .data_classes.lists.base_list import BaseList +from ..base import xml_parser + + +def get_ip_list(auth: BasicAuth, **kwargs) -> BaseList: + """ + Gets a list of IP addresses from the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + + Keyword Args: + ``` + action (str): Action to perform on the IP addresses. Defaults to "list". WARNING: SDK automatically sets this value. It is just included for completeness. + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. + ips (str): Show only certain IP addresses/ranges. One or more IPs/ranges may be specified. Multiple entries are comma separated. A host IP range is specified with a hyphen (for example, 10.10.10.44-10.10.10.90). + network_id (Union[str, int]): Network ID to filter on. Defaults to None. NOTE: This has to be enabled in the Qualys subscription! + tracking_method (Literal[None, "IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to None. Valid values are IP, DNS, NETBIOS. + compliance_enabled (Union[str, bool]): Whether to filter to IPs within the compliance module. Defaults to None. + certview_enabled (Union[str, bool]): Whether to filter to IPs within certview module. Defaults to None. + + Returns: + BaseList: BaseList object containing the IP addresses/ranges. + """ + ip_list = BaseList() + + kwargs["action"] = "list" + kwargs["echo_request"] = False + + response = call_api( + auth=auth, + module="vmdr", + endpoint="get_ip_list", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + if response.status_code == 200: + data = xml_parser(response.text)["IP_LIST_OUTPUT"] + + if "IP_SET" not in data["RESPONSE"]: + print("No IP addresses found. Returning empty BaseList.") + return ip_list + + data = data["RESPONSE"][ + "IP_SET" + ] # at this point, data has IP and IP_RANGE keys + + # Convert the IP addresses into IP objects: + if "IP" in data: + # Normalize, account for a str: + if isinstance(data["IP"], str): + data["IP"] = [data["IP"]] + single_ips = [ip for ip in data["IP"]] # Single IPs + ip_list.extend(convert_ips(single_ips)) + + # Convert the IP ranges into IPNetwork objects: + if "IP_RANGE" in data: + # Normalize, account for a str: + if isinstance(data["IP_RANGE"], str): + data["IP_RANGE"] = [data["IP_RANGE"]] + range_ips = [ip for ip in data["IP_RANGE"]] # IP Ranges + ip_list.extend(convert_ranges(range_ips)) + + else: + raise Exception(f"Failed to pull IP list. Status code: {response.status_code}") + + return ip_list + + +def add_ips( + auth: BasicAuth, + ips: Union[str, BaseList], + enable_pc: Union[bool, int] = False, + enable_vm: Union[bool, int] = True, + enable_sca: Union[bool, int] = False, + **kwargs, +) -> None: + """ + Adds IP addresses to the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + ips (Union[str, BaseList): List of IP addresses/ranges to add. Can be a string or a BaseList object containing IPs. Multiple entries are comma separated. A host IP range is specified with a hyphen. + enable_pc (Union[bool, int]): Whether to enable policy compliance tracking on the IP addresses. Defaults to False. + enable_vm (Union[bool, int]): Whether to enable vulnerability management tracking on the IP addresses. Defaults to True. + enable_sca (bool): Whether to enable SCA on the IP addresses. Defaults to False. + NOTE: EITHER enable_pc OR enable_vm MUST BE TRUE FOR THE IP ADDITION TO WORK! + + Keyword Args: + ``` + action (str): Action to perform on the IP addresses. Defaults to "add". WARNING: SDK automatically sets this value. It is just included for completeness. + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. + tracking_method (Literal["IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to "IP". Valid values are IP, DNS, NETBIOS. + owner (str): Owner of the IP addresses. Defaults to None. + ud1 (str): User-defined field 1. Defaults to None. + ud2 (str): User-defined field 2. Defaults to None. + ud3 (str): User-defined field 3. Defaults to None. + comment (str): Comment for the IP addresses. Defaults to None. + ag_title (str): Asset group title to add the IP addresses to. Defaults to None. Required if user is a Unit Manager. + enable_certview (bool): Whether to enable CertView on the IP addresses. Defaults to False. + ``` + + Returns: + None + """ + + # Check if either enable_pc or enable_vm is True: + if not enable_pc and not enable_vm: + raise ValueError("Either enable_pc or enable_vm must be True!") + + # Check tracking_method: + if "tracking_method" in kwargs and kwargs["tracking_method"] not in [ + "IP", + "DNS", + "NETBIOS", + ]: + raise ValueError( + f"Invalid tracking method. Valid values are IP, DNS, NETBIOS, not {kwargs['tracking_method']}." + ) + + kwargs["action"] = "add" + kwargs["echo_request"] = False + + # If ips is a BaseList object, convert it to a list of strings: + if isinstance(ips, BaseList): + ips = [str(ip) for ip in ips] + ips = ",".join(ips) + + kwargs["ips"] = ips + kwargs["enable_pc"] = enable_pc + kwargs["enable_vm"] = enable_vm + kwargs["enable_sca"] = enable_sca + + response = call_api( + auth=auth, + module="vmdr", + endpoint="add_ips", + payload=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] + + print(result) + + +def update_ips(auth: BasicAuth, ips: Union[str, BaseList], **kwargs) -> None: + """ + Update specific details of IP addresses in the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + ips (Union[str, BaseList): List of IP addresses/ranges to update. Can be a string or a BaseList object containing IPs. Multiple entries are comma separated. A host IP range is specified with a hyphen. + + Keyword Args: + ``` + action (str): Action to perform on the IP addresses. Defaults to "update". WARNING: SDK automatically sets this value. It is just included for completeness. + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: SDK automatically sets this value. It is just included for completeness. + network_id (Union[str, int]): Network ID to filter on. Defaults to None. NOTE: This has to be enabled in the Qualys subscription! + tracking_method (Literal["IP", "DNS", "NETBIOS"]): Tracking method to filter on. Defaults to "IP". Valid values are IP, DNS, NETBIOS. + host_dns (str): The DNS hostname for the IP you want to update. A single IP must be specified in the same request and the IP will only be updated if it matches the hostname specified. + host_netbios (str): The NetBIOS hostname for the IP you want to update. A single IP must be specified in the same request and the IP will only be updated if it matches the hostname specified. + owner (str): Owner of the IP addresses. Defaults to None. + ud1 (str): User-defined field 1. Defaults to None. + ud2 (str): User-defined field 2. Defaults to None. + ud3 (str): User-defined field 3. Defaults to None. + comment (str): Comment for the IP addresses. Defaults to None. + + Returns: + None + """ + + kwargs["action"] = "update" + kwargs["echo_request"] = False + + # If ips is a BaseList object, convert it to a list of strings: + if isinstance(ips, BaseList): + ips = [str(ip) for ip in ips] + ips = ",".join(ips) + + kwargs["ips"] = ips + + response = call_api( + auth=auth, + module="vmdr", + endpoint="update_ips", + payload=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + result = xml_parser(response.text)["SIMPLE_RETURN"]["RESPONSE"]["TEXT"] + + print(result) diff --git a/qualyspy/vmdr/query_kb.py b/qualyspy/vmdr/query_kb.py index e2520bb..88913f8 100644 --- a/qualyspy/vmdr/query_kb.py +++ b/qualyspy/vmdr/query_kb.py @@ -1,113 +1,113 @@ -""" -query_kb.py - contains the query_kb function for the Qualyspy package. - -This function is used to query the Qualys KnowledgeBase (KB), which is a database of vulnerabilities and their details. -""" - -from typing import * -from urllib.parse import parse_qs, urlparse - -from .data_classes.kb_entry import KBEntry -from .data_classes.lists.base_list import BaseList -from ..base.call_api import call_api -from ..auth.token import BasicAuth -from ..base.xml_parser import xml_parser - - -def query_kb(auth: BasicAuth, **kwargs) -> List[KBEntry]: - """ - Query the Qualys KnowledgeBase (KB) for vulnerabilities matching the kiven kwargs. - Can be used to download the entire KB (no kwargs) or to search for specific vulnerabilities. - NOTE: this function automatically appends 'action=list' to the kwargs. You do not need to include it. Should you do so, it will be overwritten. It is just recognized as valid for the sake of completeness. - - Args: - auth (BasicAuth) The authentication object. - API params (kwargs): - ``` - action (str) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. - code_modified_after (str) #The date to search for vulnerabilities modified after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - code_modified_before (str) #The date to search for vulnerabilities modified before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - echo_request (str) #Response will include the request you sent. - details Literal["Basic", "All", "None"]: #The level of detail to return. Default is 'Basic'. - ids (str) #The IDs of the vulnerabilities to return as a comma-separated string. - id_min (int) #The minimum ID of the vulnerabilities to return. - id_max (int) #The maximum ID of the vulnerabilities to return. - is_patchable (bool) #Whether the vulnerability is patchable. Default is 'False'. - last_modified_after (str) #The date to search for vulnerabilities last modified after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - last_modified_before (str) #The date to search for vulnerabilities last modified before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - last_modified_by_user_after (str) #The date to search for vulnerabilities last modified by user after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - last_modified_by_user_before (str) #The date to search for vulnerabilities last modified by user before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - last_modified_by_service_after (str) #The date to search for vulnerabilities last modified by service after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - last_modified_by_service_before (str) #The date to search for vulnerabilities last modified by service before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - published_after (str) #The date to search for vulnerabilities published after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - published_before (str) #The date to search for vulnerabilities published before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. - discovery_method (str) #The discovery method of the vulnerability. - discovery_auth_types (str) #The authentication types used to discover the vulnerability. - show_pci_reasons (bool) #Whether to show PCI reasons. Default is 'False'. - show_supported_modules_info (bool) #Whether to show supported modules info. Default is 'False'. - show_disabled_flag (bool) #Whether to show the disabled flag. Default is 'False'. - show_qid_change_log (bool) #Whether to show the QID change log. Default is 'False'. - ``` - Returns: - List of KBEntry objects representing the vulnerabilities returned by the query. - """ - - # add the action to the kwargs: - kwargs["action"] = "list" - - responses = BaseList() - pulled = 0 - - # qualys expects all boolean values to be represented as a 0 or 1: - for key, value in kwargs.items(): - if isinstance(value, bool): - kwargs[key] = 1 if value else 0 - - while True: - # make the request: - response = call_api( - auth=auth, - module="vmdr", - endpoint="query_kb", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - if response.status_code != 200: - raise Exception(f"Error: {response.status_code} - {response.text}") - - xml = xml_parser(response.text) - - # check if there is no vuln list - if "VULN_LIST" not in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]: - break - - for e in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["VULN_LIST"][ - "VULN" - ]: - responses.append(KBEntry.from_dict(e)) # append entry - - pulled += 1 - print(f"Page {pulled} complete.") - # KB API normally does not paginate, but if it does - if "WARNING" in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]: - if "URL" in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["WARNING"]: - print( - f"Pagination detected. Pulling next page from url: {xml['KNOWLEDGE_BASE_VULN_LIST_OUTPUT']['RESPONSE']['WARNING']['URL']}" - ) - # parse the url to get the query params - ps = parse_qs( - urlparse( - xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["WARNING"][ - "URL" - ] - ).query - ) - # update the kwargs with the new params - kwargs.update(ps) - - else: - break - else: - break - - return responses +""" +query_kb.py - contains the query_kb function for the Qualyspy package. + +This function is used to query the Qualys KnowledgeBase (KB), which is a database of vulnerabilities and their details. +""" + +from typing import * +from urllib.parse import parse_qs, urlparse + +from .data_classes.kb_entry import KBEntry +from .data_classes.lists.base_list import BaseList +from ..base.call_api import call_api +from ..auth.token import BasicAuth +from ..base.xml_parser import xml_parser + + +def query_kb(auth: BasicAuth, **kwargs) -> List[KBEntry]: + """ + Query the Qualys KnowledgeBase (KB) for vulnerabilities matching the kiven kwargs. + Can be used to download the entire KB (no kwargs) or to search for specific vulnerabilities. + NOTE: this function automatically appends 'action=list' to the kwargs. You do not need to include it. Should you do so, it will be overwritten. It is just recognized as valid for the sake of completeness. + + Params: + auth (BasicAuth) The authentication object. + API params (kwargs): + ``` + action (str) #The action to perform. Default is 'list'. WARNING: any value you pass is overwritten with 'list'. It is just recognized as valid for the sake of completeness. + code_modified_after (str) #The date to search for vulnerabilities modified after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + code_modified_before (str) #The date to search for vulnerabilities modified before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + echo_request (str) #Response will include the request you sent. + details Literal["Basic", "All", "None"]: #The level of detail to return. Default is 'Basic'. + ids (str) #The IDs of the vulnerabilities to return as a comma-separated string. + id_min (int) #The minimum ID of the vulnerabilities to return. + id_max (int) #The maximum ID of the vulnerabilities to return. + is_patchable (bool) #Whether the vulnerability is patchable. Default is 'False'. + last_modified_after (str) #The date to search for vulnerabilities last modified after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + last_modified_before (str) #The date to search for vulnerabilities last modified before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + last_modified_by_user_after (str) #The date to search for vulnerabilities last modified by user after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + last_modified_by_user_before (str) #The date to search for vulnerabilities last modified by user before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + last_modified_by_service_after (str) #The date to search for vulnerabilities last modified by service after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + last_modified_by_service_before (str) #The date to search for vulnerabilities last modified by service before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + published_after (str) #The date to search for vulnerabilities published after Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + published_before (str) #The date to search for vulnerabilities published before Formatted as 'YYYY-MM-DD[THH:MM:SSZ]' format UTC/GMT. + discovery_method (str) #The discovery method of the vulnerability. + discovery_auth_types (str) #The authentication types used to discover the vulnerability. + show_pci_reasons (bool) #Whether to show PCI reasons. Default is 'False'. + show_supported_modules_info (bool) #Whether to show supported modules info. Default is 'False'. + show_disabled_flag (bool) #Whether to show the disabled flag. Default is 'False'. + show_qid_change_log (bool) #Whether to show the QID change log. Default is 'False'. + ``` + Returns: + List of KBEntry objects representing the vulnerabilities returned by the query. + """ + + # add the action to the kwargs: + kwargs["action"] = "list" + + responses = BaseList() + pulled = 0 + + # qualys expects all boolean values to be represented as a 0 or 1: + for key, value in kwargs.items(): + if isinstance(value, bool): + kwargs[key] = 1 if value else 0 + + while True: + # make the request: + response = call_api( + auth=auth, + module="vmdr", + endpoint="query_kb", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + if response.status_code != 200: + raise Exception(f"Error: {response.status_code} - {response.text}") + + xml = xml_parser(response.text) + + # check if there is no vuln list + if "VULN_LIST" not in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]: + break + + for e in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["VULN_LIST"][ + "VULN" + ]: + responses.append(KBEntry.from_dict(e)) # append entry + + pulled += 1 + print(f"Page {pulled} complete.") + # KB API normally does not paginate, but if it does + if "WARNING" in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]: + if "URL" in xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["WARNING"]: + print( + f"Pagination detected. Pulling next page from url: {xml['KNOWLEDGE_BASE_VULN_LIST_OUTPUT']['RESPONSE']['WARNING']['URL']}" + ) + # parse the url to get the query params + ps = parse_qs( + urlparse( + xml["KNOWLEDGE_BASE_VULN_LIST_OUTPUT"]["RESPONSE"]["WARNING"][ + "URL" + ] + ).query + ) + # update the kwargs with the new params + kwargs.update(ps) + + else: + break + else: + break + + return responses diff --git a/qualyspy/vmdr/vmscans.py b/qualyspy/vmdr/vmscans.py index 084925b..05f7993 100644 --- a/qualyspy/vmdr/vmscans.py +++ b/qualyspy/vmdr/vmscans.py @@ -1,96 +1,202 @@ -""" -vmscans.py - Contains functions to interact with the /api/2.0/fo/scan/ API endpoint, -with the 'action' parameter controlling what is done with the scan(s). -""" - -from datetime import datetime - -from qualyspy.base import call_api, xml_parser -from .data_classes.lists.base_list import BaseList -from .data_classes.vmscan import VMScan -from ..auth.token import BasicAuth -from ..exceptions.Exceptions import * - - -def get_scan_list(auth: BasicAuth, **kwargs) -> BaseList[VMScan]: - """ - Pull a list of scans in the Qualys subscription. - - Args: - auth (BasicAuth): Qualys BasicAuth object. - - Keyword Args: - ``` - SCAN LIST FILTERS: - scan_ref (str): Scan reference ID. Formatted like scan/1234567890123456, compliance/1234567890123456, or qscap/1234567890123456. - state (Union[Literal["Running", "Paused", "Canceled", "Finished", "Error", "Queued", "Loading"], BaseList[str]): State of the scan. Multiple states can be specified as a comma-separated string. Can also pass a BaseList of strings. - processed (bool): Whether the scan has been processed. Defaults to None. - type (Literal["On-Demand", "API", "Scheduled"]): Type of scan. Defaults to None. - target (Union[str, BaseList[str], IPv4Address, IPv4Network, BaseList[IPv4Address], BaseList[IPv4Network]]) Target IP(s) of the scan. Can be a single IP string, an IP network, or a BaseList of IPs/networks. Calls API with the correct format - a comma separated string. If an IP range/network obj is passed, it is formatted as 1.2.3.4-5.6.7.8. - user_login (str): Filter on owner of the scan. Defaults to None. - launched_after_datetime (Union[strm datetime]): Filter on scans launched after a certain datetime. Can be a string or a datetime object. If a datetime object is passed, it is converted to a string. - launched_before_datetime (Union[str, datetime]): Filter on scans launched before a certain datetime. Can be a string or a datetime object. If a datetime object is passed, it is converted to a string. - scan_type (Literal["certview", "ec2certview"]): Filter on scan type. Defaults to None. - client_id (Union[str, int]): Filter on client ID. Defaults to None. - client_name (str): Filter on client name. Defaults to None. - - SHOW/HIDE FIELDS: - show_ags (bool): Whether to show AGs. Defaults to None. - show_op (bool): Whether to show OP. Defaults to None. - show_status (bool): Whether to show status. Defaults to None. - show_last (bool): Whether to show last scan. Defaults to None. - ignore_target (bool): Whether to ignore the target. Defaults to None. - ``` - - Returns: - BaseList: BaseList object containing the scan(s) in the Qualys subscription. - """ - - DT_FIELDS = ["launched_after_datetime", "launched_before_datetime"] - - # If user passes a datetime object, convert it to a string: - for field in DT_FIELDS: - if field in kwargs and isinstance(kwargs[field], datetime): - kwargs[field] = kwargs[field].strftime("%Y-%m-%dT%H:%M:%S") - - # For all other kwargs, check if they are BaseList/list objects and convert them to a comma-separated string: - for key in kwargs: - if type(kwargs[key]) in [BaseList, list]: - kwargs[key] = ",".join(kwargs[key]) - - kwargs["action"] = "list" - - # Make the request: - response = call_api( - auth=auth, - module="vmdr", - endpoint="list_scans", - params=kwargs, - headers={"X-Requested-With": "qualyspy SDK"}, - ) - - # Parse the response: - result = xml_parser(response.text) - - # Check for empty results: - if not result: - print("No scans found.") - return None - - data = result["SCAN_LIST_OUTPUT"]["RESPONSE"] - - if "SCAN_LIST" not in data or "SCAN" not in data["SCAN_LIST"]: - print("No scans found.") - return None - - # If data["SCAN_LIST"]["SCAN"] is a dict, convert it to a list of dicts: - if isinstance(data["SCAN_LIST"]["SCAN"], dict): - data["SCAN_LIST"]["SCAN"] = [data["SCAN_LIST"]["SCAN"]] - - # Convert the data to a BaseList of VMScan objects: - scans = BaseList() - - for scan in data["SCAN_LIST"]["SCAN"]: - scans.append(VMScan.from_dict(scan)) - - return scans +""" +vmscans.py - Contains functions to interact with the /api/2.0/fo/scan/ API endpoint, +with the 'action' parameter controlling what is done with the scan(s). +""" + +from datetime import datetime +from typing import Tuple + +from qualyspy.base import call_api, xml_parser +from .data_classes.lists.base_list import BaseList +from .data_classes.vmscan import VMScan +from ..auth.token import BasicAuth +from ..exceptions.Exceptions import * + + +def get_scan_list(auth: BasicAuth, **kwargs) -> BaseList[VMScan]: + """ + Pull a list of scans in the Qualys subscription. + + Params: + auth (BasicAuth): Qualys BasicAuth object. + + Keyword Args: + ``` + SCAN LIST FILTERS: + scan_ref (str): Scan reference ID. Formatted like scan/1234567890123456, compliance/1234567890123456, or qscap/1234567890123456. + state (Union[Literal["Running", "Paused", "Canceled", "Finished", "Error", "Queued", "Loading"], BaseList[str]): State of the scan. Multiple states can be specified as a comma-separated string. Can also pass a BaseList of strings. + processed (bool): Whether the scan has been processed. Defaults to None. + type (Literal["On-Demand", "API", "Scheduled"]): Type of scan. Defaults to None. + target (Union[str, BaseList[str]]) Target IP(s) of the scan. Can be a single IP string, or a BaseList of strings. Calls API with the correct format - a comma separated string. If an IP range/network obj is passed, it is formatted as 1.2.3.4-5.6.7.8. + user_login (str): Filter on owner of the scan. Defaults to None. + launched_after_datetime (Union[strm datetime]): Filter on scans launched after a certain datetime. Can be a string or a datetime object. If a datetime object is passed, it is converted to a string. + launched_before_datetime (Union[str, datetime]): Filter on scans launched before a certain datetime. Can be a string or a datetime object. If a datetime object is passed, it is converted to a string. + scan_type (Literal["certview", "ec2certview"]): Filter on scan type. Defaults to None. + client_id (Union[str, int]): Filter on client ID. Defaults to None. + client_name (str): Filter on client name. Defaults to None. + + SHOW/HIDE FIELDS: + show_ags (bool): Whether to show AGs. Defaults to None. + show_op (bool): Whether to show OP. Defaults to None. + show_status (bool): Whether to show status. Defaults to None. + show_last (bool): Whether to show last scan. Defaults to None. + ignore_target (bool): Whether to ignore the target. Defaults to None. + ``` + + Returns: + BaseList: BaseList object containing the scan(s) in the Qualys subscription. + """ + + DT_FIELDS = ["launched_after_datetime", "launched_before_datetime"] + + # If user passes a datetime object, convert it to a string: + for field in DT_FIELDS: + if field in kwargs and isinstance(kwargs[field], datetime): + kwargs[field] = kwargs[field].strftime("%Y-%m-%dT%H:%M:%S") + + # For all other kwargs, check if they are BaseList/list objects and convert them to a comma-separated string: + for key in kwargs: + if type(kwargs[key]) in [BaseList, list]: + kwargs[key] = ",".join(kwargs[key]) + + kwargs["action"] = "list" + + # Make the request: + response = call_api( + auth=auth, + module="vmdr", + endpoint="list_scans", + params=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + # Parse the response: + result = xml_parser(response.text) + + # Check for empty results: + if not result: + print("No scans found.") + return None + + data = result["SCAN_LIST_OUTPUT"]["RESPONSE"] + + if "SCAN_LIST" not in data or "SCAN" not in data["SCAN_LIST"]: + print("No scans found.") + return None + + # If data["SCAN_LIST"]["SCAN"] is a dict, convert it to a list of dicts: + if isinstance(data["SCAN_LIST"]["SCAN"], dict): + data["SCAN_LIST"]["SCAN"] = [data["SCAN_LIST"]["SCAN"]] + + # Convert the data to a BaseList of VMScan objects: + scans = BaseList() + + for scan in data["SCAN_LIST"]["SCAN"]: + scans.append(VMScan.from_dict(scan)) + + return scans + + +def launch_scan(auth: BasicAuth, **kwargs) -> VMScan: + """ + Create a new VMDR scan and launch, or call + a pre-existing VMDR scan on supplied target (IP, asset group IDs, etc.). + + Keyword args: + ``` + action (Literal["launch"]): The action to perform. Defaults to "launch". WARNING: the SDK will force-set this to "launch". + echo_request (bool): Whether to echo the request. Defaults to False. WARNING: the SDK will force-set this to False. + runtime_http_header (str): The value the scanner will put in the Qualys-Scan header. Defaults to None. Used to "drop defenses". + scan_title (str): The title of the scan. Defaults to None. + option_id (int): The option profile ID. Required if option_title not specified. + option_title (str): The option profile title. Required if option_id not specified. + + SCANNER APPLIANCE OPTIONS: + iscanner_id (int): The internal scanner ID. Defaults to None. + iscanner_name (str): The internal scanner name. Defaults to None. + ec2_instance_ids (str): The EC2 instance IDs of external scanners. Defaults to None. + + priority (int, 0-10): The priority of the scan. Defaults to None. + + ASSET IPs/GROUP OPTIONS: + ip (Union[str, BaseList[str]]): The IP(s) to scan. Defaults to None. + asset_group_ids (Union[str, BaseList[str], int, BaseList[int]]): The asset group IDs to scan. Defaults to None. + asset_groups (Union[str, BaseList[str]]): The asset group titles to scan. Defaults to None. + exclude_ip_per_scan (Union[str, BaseList[str]]): The IPs to exclude from the scan. Defaults to None. Only valid when target_from=assets. + fqdn (Union[str, BaseList[str]]): The FQDN(s) to scan. Defaults to None. + default_scanner (bool): Whether to use the default scanner. Defaults to None. + scanners_in_ag (bool): Whether to use scanners in the asset group. Defaults to None. + target_from (Literal["assets", "tags"]): The target source. Defaults to "assets". + use_ip_nt_range_tags_include (bool): Whether to use IP/NT range tags include. Defaults to None. + use_ip_nt_range_tags_exclude (bool): Whether to use IP/NT range tags exclude. Defaults to None. + use_ip_nt_range_tags (bool): Whether to use IP/NT range tags. Defaults to None. + tag_include_selector (Literal["any", "all"]): The tag include selector. Defaults to any. + tag_exclude_selector (Literal["any", "all"]): The tag exclude selector. Defaults to any. + tag_set_by (Literal["id", "name"]) Whether to search for tags by ID or name. Defaults to id. + tag_set_exclude (str): Comma-separated string of tag IDs or names, according to tag_set_by. Defaults to None. + tag_set_include (str): Comma-separated string of tag IDs or names, according to tag_set_by. Defaults to None. + + ip_network_id (int): The IP network ID. Defaults to None. Must be enabled in the Qualys subscription. + client_id (int): The client ID. Defaults to None. Only available for consultant subscriptions. + client_name (str): The client name. Defaults to None. Only available for consultant subscriptions. + + EXAMPLE: + # Launch a scan on a single IP: + result = launch_scan(auth, ip="1.2.3.4", scan_title="My Scan", priority=5, option_id=123456) + >>> "New vm scan launched with REF: scan/1234567890123456" + result + >>> ("New vm scan launched", "scan/1234567890123456") + ``` + + Returns: + VMSan: VMScan dataclass object containing the scan details. + """ + + # Set the required kwargs: + kwargs["action"] = "launch" + kwargs["echo_request"] = False + + # Convert any BaseList objects to comma-separated strings: + for key in kwargs: + if isinstance(kwargs[key], BaseList): + kwargs[key] = ",".join(kwargs[key]) + + # Make the request: + response = call_api( + auth=auth, + module="vmdr", + endpoint="launch_scan", + payload=kwargs, + headers={"X-Requested-With": "qualyspy SDK"}, + ) + + # Parse the response: + result = xml_parser(response.text) + + # Check for empty results: + if not result: + print("No scan launched.") + return None + + # Check for scan details in simple_return: + + data = result["SIMPLE_RETURN"]["RESPONSE"] + + if "CODE" in data.keys(): + raise QualysAPIError(data["TEXT"]) + + items_list = data["ITEM_LIST"]["ITEM"] + # if items_list is a dict, put it into a list: + if isinstance(items_list, dict): + items_list = [items_list] + + scan_ref = "" + for item in items_list: + # Check if the KEY key's value is REFERENCE + if item["KEY"] == "REFERENCE": + scan_ref = item["VALUE"] + + print(f'{data["TEXT"]} with REF: {scan_ref}') + + # Return a VMScan object with the scan details: + return get_scan_list(auth, scan_ref=scan_ref)[0]